perf: lazy-load js-yaml (~107KB) as needed (#2551)

- Closes: #2532
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 3fe5e4004a
commit d833edb65b
18 changed files with 413 additions and 134 deletions
@@ -1,12 +0,0 @@
import yaml from 'js-yaml';
// Converts a structured data object into preformatted text strings for a
// notification's context section. Arrays produce one string per item (strings
// pass through; objects are YAML-dumped); all other objects produce a single
// YAML-dumped string.
export const dataToContext = (data: object): string[] => {
if (Array.isArray(data)) {
return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item)));
}
return [yaml.dump(data)];
};
+1 -2
View File
@@ -4,7 +4,6 @@ import type {
} from '../../config/schema/actions/types.js';
import type { Link } from '../../config/schema/common/link.js';
import { getContextFromError } from '../../utils/error-context.js';
import { dataToContext } from './data-to-context.js';
const DEFAULT_ERROR_ICON = 'mdi:alert';
@@ -31,7 +30,7 @@ export const createNotificationFromText = (
...(options?.metadata && { metadata: options.metadata }),
...(options?.link && { link: options.link }),
...(options?.context && {
context: dataToContext(options.context),
context: [options.context],
}),
...(options?.in_progress !== undefined && { in_progress: options.in_progress }),
});
@@ -0,0 +1,52 @@
import type * as JsYaml from 'js-yaml';
import type { ReactiveControllerHost } from 'lit';
import type { Notification } from '../../config/schema/actions/types.js';
// `js-yaml` (~107KB) is only needed to render a notification's diagnostic
// context when that context contains a structured object, which most cards
// never show. It is imported on demand and shared across every notification.
export class NotificationContextController {
private static jsYamlDump: typeof JsYaml.dump | null = null;
private _host: ReactiveControllerHost;
constructor(host: ReactiveControllerHost) {
this._host = host;
}
/**
* Resolve a notification's context into display strings. String items pass
* through unchanged; object items are YAML-dumped, loading `js-yaml` on
* demand and re-rendering once it is ready. Returns an empty array while a
* load needed for an object item is still in flight.
*/
public getContext(notification: Notification): string[] {
const context = notification.context ?? [];
const dump = NotificationContextController.jsYamlDump;
const result: string[] = [];
for (const item of context) {
if (typeof item === 'string') {
result.push(item);
} else if (dump) {
result.push(dump(item));
} else {
// Object item needs js-yaml; load it and re-render once ready. A failed
// load is swallowed: the notification renders without the dumped
// context.
this._loadDumper().catch(() => {});
return [];
}
}
return result;
}
private async _loadDumper(): Promise<void> {
// `import()` is module-cached, so repeat/concurrent calls share one
// download; a failed load is not cached, so a later render retries.
const module = await import('js-yaml');
NotificationContextController.jsYamlDump = module.dump;
this._host.requestUpdate();
}
}
@@ -0,0 +1,65 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { hasPopOutAnimationEnded } from '../../utils/animation.js';
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
// Manages the popup notification's modal interaction: dismiss on outside
// interaction or Escape, and emit the dismiss event once the pop-out animation
// finishes.
export class NotificationPopupController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _getNotificationElement: () => HTMLElement | null;
constructor(
host: ReactiveControllerHost & HTMLElement,
getNotificationElement: () => HTMLElement | null,
) {
this._host = host;
this._getNotificationElement = getNotificationElement;
host.addController(this);
}
public hostConnected(): void {
window.addEventListener('click', this._handleOutsideInteraction);
window.addEventListener('focusin', this._handleOutsideInteraction);
// Escape is claimed in the capture phase: the popup is a modal surface and
// must consume Escape before non-modal background controls (e.g. the call
// controls) that also listen on `window`.
window.addEventListener('keydown', this._handleKeyDown, { capture: true });
}
public hostDisconnected(): void {
window.removeEventListener('click', this._handleOutsideInteraction);
window.removeEventListener('focusin', this._handleOutsideInteraction);
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
}
public dismiss = (): void => {
this._getNotificationElement()?.classList.add('exiting');
};
public handleAnimationEnd = (ev: AnimationEvent): void => {
if (hasPopOutAnimationEnded(ev)) {
dispatchDismissNotificationEvent(this._host);
}
};
private _handleOutsideInteraction = (ev: Event): void => {
if (!ev.composedPath().includes(this._host)) {
this.dismiss();
}
};
private _handleKeyDown = (ev: KeyboardEvent): void => {
if (ev.key === 'Escape') {
this.dismiss();
// `stopImmediatePropagation()` (not `stopPropagation()`) is required to
// block sibling `window` listeners -- `stopPropagation()` only stops
// propagation to other targets, not other listeners on `window` itself.
ev.stopImmediatePropagation();
ev.preventDefault();
}
};
}