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,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { dataToContext } from '../../../src/components-lib/notification/data-to-context';
describe('dataToContext', () => {
it('should return an array of string items unchanged when input is an array of strings', () => {
expect(dataToContext(['line one', 'line two'])).toEqual(['line one', 'line two']);
});
it('should YAML-dump object items when input is an array containing objects', () => {
const result = dataToContext([{ key: 'value' }]);
expect(result).toHaveLength(1);
expect(result[0]).toContain('key: value');
});
it('should handle a mixed array of strings and objects', () => {
const result = dataToContext(['plain string', { foo: 'bar' }]);
expect(result).toHaveLength(2);
expect(result[0]).toBe('plain string');
expect(result[1]).toContain('foo: bar');
});
it('should return a single YAML-dumped string for a plain object', () => {
const result = dataToContext({ error: 'something went wrong', code: 42 });
expect(result).toHaveLength(1);
expect(result[0]).toContain('error: something went wrong');
expect(result[0]).toContain('code: 42');
});
it('should return an empty array for an empty array input', () => {
expect(dataToContext([])).toEqual([]);
});
});
@@ -67,8 +67,7 @@ describe('createNotificationFromText', () => {
const notification = createNotificationFromText('oops', {
context: { detail: 'extra info' },
});
expect(notification.context).toBeDefined();
expect(notification.context?.join(' ')).toContain('detail: extra info');
expect(notification.context).toEqual([{ detail: 'extra info' }]);
});
it('should omit context when not provided', () => {
@@ -136,8 +135,7 @@ describe('createNotificationFromError', () => {
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
const notification = createNotificationFromError(error);
assert(notification);
expect(notification.context).toBeDefined();
expect(notification.context?.join(' ')).toContain('reason: network');
expect(notification.context).toEqual([{ reason: 'network' }]);
});
it('should use explicit context option over AdvancedCameraCardError context', () => {
@@ -146,8 +144,7 @@ describe('createNotificationFromError', () => {
context: { override: 'explicit' },
});
assert(notification);
expect(notification.context?.join(' ')).toContain('override: explicit');
expect(notification.context?.join(' ')).not.toContain('reason: network');
expect(notification.context).toEqual([{ override: 'explicit' }]);
});
it('should not include context when AdvancedCameraCardError has a non-object context', () => {
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Notification } from '../../../src/config/schema/actions/types';
import { createLitElement, flushPromises } from '../../test-utils';
// @vitest-environment jsdom
describe('NotificationContextController', () => {
// Each test re-imports the controller after resetting the module registry so its
// module-level `js-yaml` singleton starts unloaded.
const loadController = async () => {
const module = await import(
'../../../src/components-lib/notification/notification-context-controller'
);
return module.NotificationContextController;
};
const createNotification = (context?: Notification['context']): Notification => ({
body: { text: 'oops' },
...(context && { context }),
});
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('js-yaml');
});
it('should return an empty array when the notification has no context', async () => {
const NotificationContextController = await loadController();
const controller = new NotificationContextController(createLitElement());
expect(controller.getContext(createNotification())).toEqual([]);
});
it('should return string context items unchanged without loading the library', async () => {
const NotificationContextController = await loadController();
const host = createLitElement();
const controller = new NotificationContextController(host);
expect(controller.getContext(createNotification(['line one', 'line two']))).toEqual([
'line one',
'line two',
]);
expect(host.requestUpdate).not.toHaveBeenCalled();
});
it('should YAML-dump object context items once the library has loaded', async () => {
const NotificationContextController = await loadController();
const host = createLitElement();
const controller = new NotificationContextController(host);
const notification = createNotification([{ foo: 'bar' }]);
// The library is not yet loaded, so the first render defers.
expect(controller.getContext(notification)).toEqual([]);
await vi.waitFor(() => expect(host.requestUpdate).toHaveBeenCalled());
const result = controller.getContext(notification);
expect(result).toHaveLength(1);
expect(result[0]).toContain('foo: bar');
});
it('should defer the whole context until the library loads when any item is an object', async () => {
const NotificationContextController = await loadController();
const host = createLitElement();
const controller = new NotificationContextController(host);
const notification = createNotification(['plain string', { foo: 'bar' }]);
expect(controller.getContext(notification)).toEqual([]);
await vi.waitFor(() => expect(host.requestUpdate).toHaveBeenCalled());
const result = controller.getContext(notification);
expect(result[0]).toBe('plain string');
expect(result[1]).toContain('foo: bar');
});
it('should swallow a failed library load and render without the dumped context', async () => {
vi.doMock('js-yaml', () => {
throw new Error('chunk load failed');
});
const NotificationContextController = await loadController();
const host = createLitElement();
const controller = new NotificationContextController(host);
const notification = createNotification([{ foo: 'bar' }]);
expect(controller.getContext(notification)).toEqual([]);
// Allow the rejected dynamic import to settle.
await flushPromises();
expect(host.requestUpdate).not.toHaveBeenCalled();
expect(controller.getContext(notification)).toEqual([]);
});
});
@@ -0,0 +1,139 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller';
import { POP_OUT_ANIMATION_NAME } from '../../../src/const';
import { createLitElement } from '../../test-utils';
// @vitest-environment jsdom
describe('NotificationPopupController', () => {
const create = (getNotificationElement?: () => HTMLElement | null) => {
const host = createLitElement();
document.body.appendChild(host);
const popup = document.createElement('div');
const controller = new NotificationPopupController(
host,
getNotificationElement ?? (() => popup),
);
controller.hostConnected();
// Cleanup (disconnecting the window listeners and clearing the DOM) is
// registered per test, so leaked listeners cannot bleed into later tests.
onTestFinished(() => {
controller.hostDisconnected();
document.body.replaceChildren();
});
return { host, popup, controller };
};
it('should add itself to the host', () => {
const { host, controller } = create();
expect(host.addController).toHaveBeenCalledWith(controller);
});
describe('dismiss', () => {
it('should mark the notification element as exiting', () => {
const { popup, controller } = create();
controller.dismiss();
expect(popup.classList.contains('exiting')).toBe(true);
});
it('should do nothing when there is no notification element', () => {
const controller = new NotificationPopupController(createLitElement(), () => null);
expect(() => controller.dismiss()).not.toThrow();
});
});
describe('outside interaction', () => {
it('should dismiss on a click outside the host', () => {
const { popup } = create();
const outside = document.createElement('div');
document.body.appendChild(outside);
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
expect(popup.classList.contains('exiting')).toBe(true);
});
it('should dismiss on a focus outside the host', () => {
const { popup } = create();
const outside = document.createElement('div');
document.body.appendChild(outside);
outside.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
expect(popup.classList.contains('exiting')).toBe(true);
});
it('should not dismiss on an interaction inside the host', () => {
const { host, popup } = create();
host.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
expect(popup.classList.contains('exiting')).toBe(false);
});
it('should stop listening once disconnected', () => {
const { popup, controller } = create();
controller.hostDisconnected();
const outside = document.createElement('div');
document.body.appendChild(outside);
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
expect(popup.classList.contains('exiting')).toBe(false);
});
});
describe('keydown', () => {
it('should dismiss and consume the Escape key', () => {
const { popup } = create();
const ev = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(ev);
expect(popup.classList.contains('exiting')).toBe(true);
expect(ev.defaultPrevented).toBe(true);
});
it('should ignore other keys', () => {
const { popup } = create();
const ev = new KeyboardEvent('keydown', {
key: 'a',
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(ev);
expect(popup.classList.contains('exiting')).toBe(false);
expect(ev.defaultPrevented).toBe(false);
});
});
describe('animation end', () => {
// Dispatch a real `animationend` event on the element the handler is bound
// to, so `target` and `currentTarget` are genuinely the same node.
const dispatchAnimationEnd = (
controller: NotificationPopupController,
animationName: string,
): void => {
const element = document.createElement('div');
element.addEventListener('animationend', controller.handleAnimationEnd);
const ev = new Event('animationend');
Object.defineProperty(ev, 'animationName', { value: animationName });
element.dispatchEvent(ev);
};
it('should dispatch the dismiss event when the pop-out animation ends', () => {
const { host, controller } = create();
const dismissed = vi.fn();
host.addEventListener('advanced-camera-card:notification:dismiss', dismissed);
dispatchAnimationEnd(controller, POP_OUT_ANIMATION_NAME);
expect(dismissed).toHaveBeenCalled();
});
it('should ignore other animations ending', () => {
const { host, controller } = create();
const dismissed = vi.fn();
host.addEventListener('advanced-camera-card:notification:dismiss', dismissed);
dispatchAnimationEnd(controller, 'pop-in');
expect(dismissed).not.toHaveBeenCalled();
});
});
});