Skip to content

Commit e33df11

Browse files
bloveclaude
andauthored
fix(chat): lifecycle-guaranteed root token injection (production regression) (#375)
`ensureChatRootStyles()` injects the chat lib's root CSS custom properties — `--ngaf-chat-bg`, `--ngaf-chat-surface`, `--ngaf-chat-radius-input`, `--ngaf-chat-sidenav-width-expanded`, `--ngaf-chat-font-family`, the edge-claim primitive, the data-ngaf-chat-theme attribute mappings, and reduced-motion overrides. Every host-encapsulated chat lib style references these via `var()`. It was wired as a module-evaluation side effect (auto-call at the bottom of chat-tokens.ts). The published artifact's `sideEffects` glob was `["**/chat-tokens.ts", "**/*.css"]` — the chat-tokens.ts glob matches the *source* tree (workspace TS paths in this repo) but matches nothing in the bundled `dist/libs/chat/fesm2022/ngaf-chat.mjs` artifact. When a consumer's bundler does aggressive production tree-shaking it sees no side-effect-marked files in the published package and treats the entire bundle as side-effect-free. Result: the module-eval call is dropped, the style element is never injected, and every chat surface renders without chrome — sidenav has no width, input has no border, chips have no pill background, suggestions look like plain text on the page bg. This shipped in production at https://demo.cacheplane.ai but doesn't reproduce locally (dev mode skips tree-shaking; workspace TS paths match the source glob). Verified live: manual injection of the root tokens via browser console restored every chrome element instantly. Fix: - Call `ensureChatRootStyles()` from the constructors of every top-level chat composition (`ChatComponent`, `ChatPopupComponent`, `ChatSidebarComponent`, `ChatDebugComponent`). The function is idempotent (early-returns if the style element already exists). Constructors are reachable from user code, so bundlers can't tree-shake the call. - Extend `libs/chat/package.json` `sideEffects` to also match `./fesm2022/ngaf-chat.mjs` — defense-in-depth for any consumer bundler that DOES respect the field. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8e4d5fe commit e33df11

5 files changed

Lines changed: 28 additions & 3 deletions

File tree

libs/chat/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
},
4646
"sideEffects": [
4747
"**/chat-tokens.ts",
48+
"./fesm2022/ngaf-chat.mjs",
4849
"**/*.css"
4950
]
5051
}

libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { NgTemplateOutlet } from '@angular/common';
1717
import type { AgentWithHistory } from '../../agent';
1818
import { CHAT_DEBUG_TOKENS } from './chat-debug-tokens';
19+
import { ensureChatRootStyles } from '../../styles/chat-tokens';
1920
import { ChatDebugControlsDirective } from './chat-debug-controls.directive';
2021
import { ChatDebugInspectorDirective } from './chat-debug-inspector.directive';
2122
import { TimelineInspectorComponent } from './inspectors/timeline-inspector.component';
@@ -399,6 +400,10 @@ export class ChatDebugComponent {
399400
private readonly hostEl: ElementRef<HTMLElement> = inject(ElementRef);
400401

401402
constructor() {
403+
// Inject chat lib root CSS custom properties so the theme-attribute
404+
// mappings + edge-claim primitive are in the document, even when
405+
// chat-debug is mounted without a sibling chat composition.
406+
ensureChatRootStyles();
402407
// Restore once from storage on construction; inputs seed the fallback.
403408
// `storageKey` is read-once: rebinding it at runtime is not supported.
404409
const restore = createPersistence(this.storageKey());

libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ViewRegistry } from '@ngaf/render';
66
import { ChatComponent } from '../chat/chat.component';
77
import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component';
88
import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component';
9-
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
9+
import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens';
1010

1111
@Component({
1212
selector: 'chat-popup',
@@ -106,6 +106,9 @@ export class ChatPopupComponent {
106106
private readonly document = inject(DOCUMENT);
107107

108108
constructor() {
109+
// Inject chat lib root CSS custom properties — see ChatComponent
110+
// for the full rationale. Idempotent + lifecycle-guaranteed.
111+
ensureChatRootStyles();
109112
effect(() => {
110113
// Re-bind whenever shortcut/closeOnEscape change.
111114
const shortcut = this.shortcut();

libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ViewRegistry } from '@ngaf/render';
66
import { ChatComponent } from '../chat/chat.component';
77
import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component';
88
import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component';
9-
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
9+
import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens';
1010

1111
@Component({
1212
selector: 'chat-sidebar',
@@ -104,6 +104,9 @@ export class ChatSidebarComponent {
104104
readonly forkRequested = output<string>();
105105

106106
constructor() {
107+
// Inject chat lib root CSS custom properties — see ChatComponent
108+
// for the full rationale. Idempotent + lifecycle-guaranteed.
109+
ensureChatRootStyles();
107110
// Publish the right-edge claim while the panel is open. Peer panels
108111
// (e.g. chat-debug) read --ngaf-chat-occupy-right to leave room.
109112
effect(() => {

libs/chat/src/lib/compositions/chat/chat.component.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { createContentClassifier, type ContentClassifier } from '../../streaming
3333
import { createPartialArgsBridge, type PartialArgsBridge } from '../../a2ui/partial-args-bridge';
3434
import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../../a2ui/surface-store';
3535
import { messageContent } from '../shared/message-utils';
36-
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
36+
import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens';
3737
import type { ChatRenderEvent } from './chat-render-event';
3838
import { CHAT_LIFECYCLE, type ChatLifecycle } from '../../lifecycle';
3939

@@ -395,6 +395,19 @@ export class ChatComponent {
395395
private static readonly PIN_TOLERANCE_PX = 150;
396396

397397
constructor() {
398+
// Inject the chat lib's root CSS custom properties (--ngaf-chat-bg,
399+
// --ngaf-chat-surface, --ngaf-chat-radius-input, etc.) the first
400+
// time any chat composition is constructed. The module-eval side
401+
// effect that previously handled this is unreliable under
402+
// aggressive production tree-shaking — bundlers that don't see
403+
// the source `chat-tokens.ts` path in the published artifact's
404+
// `sideEffects` glob drop the call entirely, leaving consumers
405+
// with zero token defaults (sidenav has no width, input has no
406+
// border, chips have no chrome — everything renders as plain
407+
// text on the page background). Calling from a constructor that
408+
// is unconditionally reachable from user code defeats that
409+
// tree-shaking and is idempotent. */
410+
ensureChatRootStyles();
398411
effect(() => {
399412
if (this.eventsSubscribed) return;
400413
let agent: ReturnType<typeof this.agent>;

0 commit comments

Comments
 (0)