Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov 9ba003f86a Only re-render context consumers when the selected value changes (#52885)
The consume* decorators paired @lit/context's @consume({ subscribe: true })
with @transform to narrow a context down to a single value. ContextConsumer
calls host.requestUpdate() on every provider notification, so every consumer
ran an (often empty) render cycle on every unrelated statesContext change.

Replace the built-in consumer with a small reactive controller that subscribes
the same way but leaves update scheduling to the property setter, which already
gates on hasChanged. A render is now requested only when the selected value
actually changes.
2026-07-07 18:31:34 +02:00
4 changed files with 503 additions and 220 deletions
+85 -8
View File
@@ -1,5 +1,7 @@
import { consume } from "@lit/context";
import { ContextEvent } from "@lit/context";
import type { Context, ContextCallback } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveElement } from "lit";
import type {
HomeAssistant,
HomeAssistantInternationalization,
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
return previous;
};
/**
* Reactive controller that subscribes to a Lit context and assigns each
* delivered value to a host property — WITHOUT forcing a host update on every
* delivery.
*
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
* unconditionally on every provider notification. For a hot context such as
* `statesContext` (replaced on every entity state change) that means every
* consumer runs an (often empty) update/render cycle on every unrelated state
* change, even when the value it actually reads is unchanged.
*
* This controller instead leaves update scheduling to the property's own
* setter. Combined with {@link transform}, that setter only requests an update
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
* with `hasChanged`), so unrelated context churn no longer triggers renders.
*/
class ContextSubscriptionController<ValueType> implements ReactiveController {
private _unsubscribe?: () => void;
constructor(
private readonly _host: ReactiveElement,
private readonly _context: Context<unknown, ValueType>,
private readonly _assign: (value: ValueType) => void
) {
this._host.addController(this);
}
public hostConnected(): void {
this._host.dispatchEvent(
new ContextEvent(this._context, this._host, this._callback, true)
);
}
public hostDisconnected(): void {
this._unsubscribe?.();
this._unsubscribe = undefined;
}
// Class field arrow function so the identity is stable per instance, which the
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
private readonly _callback: ContextCallback<ValueType> = (
value,
unsubscribe
) => {
// A different provider answered (e.g. re-parenting); drop the stale one.
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
this._unsubscribe();
}
this._unsubscribe = unsubscribe;
// Assign through the property setter, which decides — via `hasChanged` —
// whether an update is actually needed. We intentionally never call
// `host.requestUpdate()` here.
this._assign(value);
};
}
/**
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
* host update on every provider notification — see
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
* update is scheduled only when the derived value actually changes.
*/
const subscribeContext =
<ValueType>(context: Context<unknown, ValueType>) =>
(proto: object, propertyKey: string): void => {
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
(host) => {
new ContextSubscriptionController<ValueType>(
host as ReactiveElement,
context,
(value) => {
(host as unknown as Record<string, unknown>)[propertyKey] = value;
}
);
}
);
};
const composeDecorator = <T, V>(
context: Parameters<typeof consume>[0]["context"],
context: Context<unknown, T>,
watchKey: string | undefined,
select: (this: unknown, value: T) => V | undefined
) => {
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({ context, subscribe: true });
const consumeDec = subscribeContext<T>(context);
return (proto: any, propertyKey: string) => {
transformDec(proto, propertyKey);
consumeDec(proto, propertyKey);
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({
context: statesContext,
subscribe: true,
});
const consumeDec = subscribeContext<HassEntities>(statesContext);
transformDec(proto as never, propertyKey);
consumeDec(proto as never, propertyKey);
};
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
* whenever `localize` changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
@@ -0,0 +1,417 @@
import { afterEach, describe, expect, it } from "vitest";
import { ContextProvider } from "@lit/context";
import { html, LitElement } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../../src/data/context";
import {
consumeEntityRegistryEntry,
consumeEntityState,
consumeEntityStates,
consumeLocalize,
} from "../../../src/common/decorators/consume-context-entry";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
import type { HomeAssistantInternationalization } from "../../../src/types";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
const makeEntity = (entityId: string, stateValue: string): HassEntity =>
({
entity_id: entityId,
state: stateValue,
attributes: {},
last_changed: "2024-01-01T00:00:00Z",
last_updated: "2024-01-01T00:00:00Z",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const makeRegistryEntry = (
entityId: string,
name: string
): EntityRegistryDisplayEntry => ({
entity_id: entityId,
name,
labels: [],
});
const makeI18n = (localize: LocalizeFunc): HomeAssistantInternationalization =>
({ localize }) as HomeAssistantInternationalization;
declare global {
interface HTMLElementTagNameMap {
"test-consume-entity-state": TestConsumeEntityState;
"test-consume-entity-states": TestConsumeEntityStates;
"test-consume-registry-entry": TestConsumeRegistryEntry;
"test-consume-localize": TestConsumeLocalize;
"test-consume-localize-no-state": TestConsumeLocalizeNoState;
}
}
// --- Test consumers ----------------------------------------------------------
// Counts update cycles via `updated()` (not `render()`, which lint forbids
// assigning to `this` in). One render() runs per update cycle, so this is the
// render count. With the fix, an unrelated context change schedules no update,
// so this counter does not advance.
class RenderCounter extends LitElement {
public renderCount = 0;
protected updated(changed: PropertyValues) {
super.updated(changed);
this.renderCount += 1;
}
}
@customElement("test-consume-entity-state")
class TestConsumeEntityState extends RenderCounter {
@property({ attribute: false }) public entityId = "light.kitchen";
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
public stateObj?: HassEntity;
protected render() {
return html`${this.stateObj?.state ?? "none"}`;
}
}
@customElement("test-consume-entity-states")
class TestConsumeEntityStates extends RenderCounter {
@property({ attribute: false }) public entityIds: string[] = [
"light.kitchen",
];
@state()
@consumeEntityStates({ entityIdPath: ["entityIds"] })
public stateObjs?: Record<string, HassEntity>;
protected render() {
return html`${Object.keys(this.stateObjs ?? {}).length}`;
}
}
@customElement("test-consume-registry-entry")
class TestConsumeRegistryEntry extends RenderCounter {
@property({ attribute: false }) public entityId = "light.kitchen";
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["entityId"] })
public entry?: EntityRegistryDisplayEntry;
protected render() {
return html`${this.entry?.name ?? "none"}`;
}
}
@customElement("test-consume-localize")
class TestConsumeLocalize extends RenderCounter {
@state()
@consumeLocalize()
public localize?: LocalizeFunc;
protected render() {
return html`${this.localize ? "set" : "none"}`;
}
}
// Mirrors the ~10 call sites that use `@consumeLocalize()` WITHOUT `@state()`.
@customElement("test-consume-localize-no-state")
class TestConsumeLocalizeNoState extends RenderCounter {
@consumeLocalize()
public localize?: LocalizeFunc;
protected render() {
return html`${this.localize ? "set" : "none"}`;
}
}
// --- Helpers -----------------------------------------------------------------
let host: HTMLDivElement | undefined;
afterEach(() => {
host?.remove();
host = undefined;
});
const mount = async <T extends LitElement>(
tag: string,
context: any,
initialValue: unknown
): Promise<{ el: T; provider: ContextProvider<any> }> => {
host = document.createElement("div");
document.body.appendChild(host);
const provider = new ContextProvider(host, {
context,
initialValue,
});
const el = document.createElement(tag) as T;
// The consumer must be a connected DOM descendant of the provider host.
host.appendChild(el);
await el.updateComplete;
return { el, provider };
};
// --- Tests -------------------------------------------------------------------
describe("consumeEntityState", () => {
it("renders on mount and reads the watched entity", async () => {
const { el } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{
"light.kitchen": makeEntity("light.kitchen", "on"),
"light.bedroom": makeEntity("light.bedroom", "on"),
}
);
expect(el.stateObj?.state).toBe("on");
expect(el.renderCount).toBe(1);
});
it("does NOT re-render when an unrelated entity changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
);
expect(el.renderCount).toBe(1);
// New states object, but the watched entity keeps the same reference.
provider.setValue({
"light.kitchen": kitchen,
"light.bedroom": makeEntity("light.bedroom", "off"),
});
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when the watched entity changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{ "light.kitchen": kitchen }
);
expect(el.renderCount).toBe(1);
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
await el.updateComplete;
expect(el.stateObj?.state).toBe("off");
expect(el.renderCount).toBe(2);
});
it("re-renders when the watched host property changes", async () => {
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{
"light.kitchen": makeEntity("light.kitchen", "on"),
"light.bedroom": makeEntity("light.bedroom", "off"),
}
);
expect(el.stateObj?.state).toBe("on");
el.entityId = "light.bedroom";
await el.updateComplete;
expect(el.stateObj?.state).toBe("off");
// The provider is still wired, but churn that leaves the watched entity
// untouched must not render.
const renderCount = el.renderCount;
provider.setValue({
"light.kitchen": makeEntity("light.kitchen", "on"),
"light.bedroom": el.stateObj!,
});
await el.updateComplete;
expect(el.renderCount).toBe(renderCount);
});
it("clears the value and re-renders when the watched entity is removed", async () => {
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{ "light.kitchen": makeEntity("light.kitchen", "on") }
);
expect(el.stateObj?.state).toBe("on");
expect(el.renderCount).toBe(1);
provider.setValue({});
await el.updateComplete;
expect(el.stateObj).toBeUndefined();
expect(el.renderCount).toBe(2);
});
});
describe("ContextSubscriptionController lifecycle", () => {
it("mounts without a provider: value stays undefined and renders once", async () => {
host = document.createElement("div");
document.body.appendChild(host);
const el = document.createElement(
"test-consume-entity-state"
) as TestConsumeEntityState;
host.appendChild(el);
await el.updateComplete;
expect(el.stateObj).toBeUndefined();
expect(el.renderCount).toBe(1);
});
it("unsubscribes on disconnect and re-subscribes on reconnect", async () => {
host = document.createElement("div");
document.body.appendChild(host);
const provider = new ContextProvider(host, {
context: statesContext,
initialValue: { "light.kitchen": makeEntity("light.kitchen", "on") },
});
const el = document.createElement(
"test-consume-entity-state"
) as TestConsumeEntityState;
host.appendChild(el);
await el.updateComplete;
expect(el.stateObj?.state).toBe("on");
// Disconnect, then push an update — the disconnected element must not render.
el.remove();
const renderCount = el.renderCount;
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
await el.updateComplete;
expect(el.renderCount).toBe(renderCount);
// Reconnect — it must re-subscribe and pick up the current value.
host.appendChild(el);
await el.updateComplete;
expect(el.stateObj?.state).toBe("off");
});
});
describe("consumeEntityStates", () => {
it("does NOT re-render when an entity outside the watched set changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityStates>(
"test-consume-entity-states",
statesContext,
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
);
expect(el.renderCount).toBe(1);
provider.setValue({
"light.kitchen": kitchen,
"light.bedroom": makeEntity("light.bedroom", "off"),
});
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when a watched entity changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityStates>(
"test-consume-entity-states",
statesContext,
{ "light.kitchen": kitchen }
);
expect(el.renderCount).toBe(1);
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
await el.updateComplete;
expect(el.renderCount).toBe(2);
});
});
describe("consumeEntityRegistryEntry", () => {
it("does NOT re-render when an unrelated registry entry changes", async () => {
const kitchen = makeRegistryEntry("light.kitchen", "Kitchen");
const { el, provider } = await mount<TestConsumeRegistryEntry>(
"test-consume-registry-entry",
entitiesContext,
{
"light.kitchen": kitchen,
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom"),
}
);
expect(el.entry?.name).toBe("Kitchen");
expect(el.renderCount).toBe(1);
provider.setValue({
"light.kitchen": kitchen,
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom 2"),
});
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when the watched registry entry changes", async () => {
const { el, provider } = await mount<TestConsumeRegistryEntry>(
"test-consume-registry-entry",
entitiesContext,
{
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen"),
}
);
expect(el.renderCount).toBe(1);
provider.setValue({
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen renamed"),
});
await el.updateComplete;
expect(el.entry?.name).toBe("Kitchen renamed");
expect(el.renderCount).toBe(2);
});
});
describe("consumeLocalize", () => {
const localizeA = (() => "A") as LocalizeFunc;
const localizeB = (() => "B") as LocalizeFunc;
it("does NOT re-render when the i18n context changes but localize is identical", async () => {
const { el, provider } = await mount<TestConsumeLocalize>(
"test-consume-localize",
internationalizationContext,
makeI18n(localizeA)
);
expect(el.localize).toBe(localizeA);
expect(el.renderCount).toBe(1);
// New i18n object, same localize reference (e.g. only locale changed).
provider.setValue(makeI18n(localizeA));
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when localize changes", async () => {
const { el, provider } = await mount<TestConsumeLocalize>(
"test-consume-localize",
internationalizationContext,
makeI18n(localizeA)
);
expect(el.renderCount).toBe(1);
provider.setValue(makeI18n(localizeB));
await el.updateComplete;
expect(el.localize).toBe(localizeB);
expect(el.renderCount).toBe(2);
});
it("works without @state(): updates only when localize changes", async () => {
const { el, provider } = await mount<TestConsumeLocalizeNoState>(
"test-consume-localize-no-state",
internationalizationContext,
makeI18n(localizeA)
);
expect(el.localize).toBe(localizeA);
expect(el.renderCount).toBe(1);
provider.setValue(makeI18n(localizeA));
await el.updateComplete;
expect(el.renderCount).toBe(1);
provider.setValue(makeI18n(localizeB));
await el.updateComplete;
expect(el.localize).toBe(localizeB);
expect(el.renderCount).toBe(2);
});
});
-125
View File
@@ -451,96 +451,6 @@ test.describe("Theming", () => {
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
const DASHBOARD_LINKS = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
{ href: "/knx", label: "KNX" },
{ href: "/config/thread", label: "Thread" },
{ href: "/config/bluetooth", label: "Bluetooth" },
{ href: "/config/infrared", label: "Infrared" },
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
const CONFIG_ROUTES: { path: string; element: string }[] = [
{ path: "/config/integrations", element: "ha-config-integrations" },
{ path: "/config/devices", element: "ha-config-devices" },
{ path: "/config/entities", element: "ha-config-entities" },
{ path: "/config/helpers", element: "ha-config-helpers" },
{ path: "/config/areas", element: "ha-config-areas" },
{ path: "/config/apps", element: "ha-config-apps" },
{ path: "/config/app", element: "ha-config-app-dashboard" },
{ path: "/config/automation", element: "ha-config-automation" },
{ path: "/config/backup", element: "ha-config-backup" },
{ path: "/config/scene", element: "ha-config-scene" },
{ path: "/config/script", element: "ha-config-script" },
{ path: "/config/blueprint", element: "ha-config-blueprint" },
{ path: "/config/cloud", element: "ha-config-cloud" },
{ path: "/config/energy", element: "ha-config-energy" },
{ path: "/config/hardware", element: "ha-config-hardware" },
{ path: "/config/labs", element: "ha-config-labs" },
{ path: "/config/lovelace", element: "ha-config-lovelace" },
{ path: "/config/person", element: "ha-config-person" },
{ path: "/config/storage", element: "ha-config-section-storage" },
{ path: "/config/tags", element: "ha-config-tags" },
{ path: "/config/users", element: "ha-config-users" },
{ path: "/config/voice-assistants", element: "ha-config-voice-assistants" },
{ path: "/config/system", element: "ha-config-system-navigation" },
{ path: "/config/info", element: "ha-config-info" },
{ path: "/config/logs", element: "ha-config-logs" },
{ path: "/config/general", element: "ha-config-section-general" },
{ path: "/config/updates", element: "ha-config-section-updates" },
{ path: "/config/repairs", element: "ha-config-repairs-dashboard" },
{ path: "/config/analytics", element: "ha-config-section-analytics" },
{ path: "/config/ai-tasks", element: "ha-config-section-ai-tasks" },
{ path: "/config/labels", element: "ha-config-labels" },
{ path: "/config/zone", element: "ha-config-zone" },
{ path: "/config/network", element: "ha-config-section-network" },
{
path: "/config/application_credentials",
element: "ha-config-application-credentials",
},
{ path: "/config/bluetooth", element: "bluetooth-config-dashboard-router" },
{ path: "/config/dhcp", element: "dhcp-config-panel" },
{ path: "/config/infrared", element: "infrared-config-dashboard-router" },
{ path: "/config/matter", element: "matter-config-panel" },
{ path: "/config/mqtt", element: "mqtt-config-panel" },
{
path: "/config/radio-frequency",
element: "radio-frequency-config-dashboard-router",
},
{ path: "/config/ssdp", element: "ssdp-config-panel" },
{ path: "/config/thread", element: "thread-config-panel" },
{ path: "/config/zeroconf", element: "zeroconf-config-panel" },
{ path: "/config/zha", element: "zha-config-dashboard-router" },
{ path: "/config/zwave_js", element: "zwave_js-config-router" },
];
const NESTED_CONFIG_ROUTES: { path: string; element: string }[] = [
{
path: "/config/integrations/dashboard",
element: "ha-config-integrations-dashboard",
},
{
path: "/config/devices/dashboard",
element: "ha-config-devices-dashboard",
},
{ path: "/config/areas/dashboard", element: "ha-config-areas-dashboard" },
{ path: "/config/backup/settings", element: "ha-config-backup-settings" },
];
test("config panel loads without JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
@@ -556,39 +466,4 @@ test.describe("Config panel", () => {
);
expect(realErrors).toHaveLength(0);
});
test("dashboard renders key settings links", async ({ page }) => {
await goToPanel(page, "/config");
const dashboard = page.locator("ha-config-dashboard");
await expect(dashboard).toBeAttached({ timeout: PANEL_TIMEOUT });
for (const { href, label } of DASHBOARD_LINKS) {
const link = dashboard.getByRole("link", {
name: new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
});
// eslint-disable-next-line no-await-in-loop
await expect(link).toHaveAttribute("href", href, {
timeout: QUICK_TIMEOUT,
});
}
});
for (const { path, element } of CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
for (const { path, element } of NESTED_CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
+1 -87
View File
@@ -10,10 +10,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockAssist } from "../../../../demo/src/stubs/assist";
import { mockAuth } from "../../../../demo/src/stubs/auth";
import { mockCloud } from "../../../../demo/src/stubs/cloud";
import {
demoConfigEntries,
mockConfigEntries,
} from "../../../../demo/src/stubs/config_entries";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEnergy } from "../../../../demo/src/stubs/energy";
import { energyEntities } from "../../../../demo/src/stubs/entities";
@@ -23,8 +20,6 @@ import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockIntegration } from "../../../../demo/src/stubs/integration";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
@@ -37,45 +32,9 @@ import { mockTemplate } from "../../../../demo/src/stubs/template";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockTranslations } from "../../../../demo/src/stubs/translations";
import { mockUpdate } from "../../../../demo/src/stubs/update";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import { demoConfig } from "../../../../src/fake_data/demo_config";
import { e2eTestPanels } from "./ha-test-panels";
import { scenarios } from "./scenarios";
const E2E_CONFIG_COMPONENTS = [
...demoConfig.components,
"bluetooth",
"dhcp",
"hardware",
"infrared",
"insteon",
"knx",
"lovelace",
"matter",
"mqtt",
"radio_frequency",
"ssdp",
"tag",
"thread",
"zeroconf",
"zha",
"zone",
"zwave_js",
];
const E2E_FILTER_ENTITIES: Record<string, EntityRegistryDisplayEntry> = {
"infrared.remote": {
entity_id: "infrared.remote",
labels: [],
platform: "demo",
},
"radio_frequency.remote": {
entity_id: "radio_frequency.remote",
labels: [],
platform: "demo",
},
};
declare global {
interface Window {
__mockHass: MockHomeAssistant;
@@ -97,13 +56,6 @@ export class HaTest extends HomeAssistantAppEl {
const initial: Partial<MockHomeAssistant> = {
// Use the full panel map (history + config enabled)
panels: e2eTestPanels,
config: {
...demoConfig,
// Include common protocol and discovery integrations so Settings shows
// the same high-level panels most real Home Assistant instances expose.
components: E2E_CONFIG_COMPONENTS,
},
entities: E2E_FILTER_ENTITIES,
panelUrl: (() => {
const path = window.location.pathname;
const dividerPos = path.indexOf("/", 1);
@@ -148,46 +100,8 @@ export class HaTest extends HomeAssistantAppEl {
mockEntityRegistry(hass, []);
mockConfigEntries(hass);
mockIcons(hass);
mockIntegration(hass);
mockHassioSupervisor(hass);
mockPersistentNotification(hass);
mockSearch(hass);
const { mockConfigPanel } =
await import("../../../../demo/src/stubs/config-panel");
mockConfigPanel(hass);
hass.mockWS("config_entries/get", (msg: { domain?: string }) => {
const protocolEntries = demoConfigEntries
.map(({ entry }) => entry)
.concat(
[
{ entry_id: "mock-bluetooth", domain: "bluetooth" },
{ entry_id: "mock-lovelace", domain: "lovelace" },
].map((entry) => ({
disabled_by: null,
domain: entry.domain,
entry_id: entry.entry_id,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
reason: null,
source: "user" as const,
state: "loaded" as const,
supported_subentry_types: {},
supports_options: false,
supports_reconfigure: false,
supports_remove_device: false,
supports_unload: true,
title: entry.domain,
}))
);
return protocolEntries.filter(
(entry) => !msg.domain || entry.domain === msg.domain
);
});
hass.mockWS("radio_frequency/list", () => ({ transmitters: [] }));
// Load default entities from the sections config
hass.addEntities(energyEntities());