Compare commits

...

2 Commits

Author SHA1 Message Date
Franck Nijhof f97c02500b Extract weakMemoize helper and use it for the entities cache 2026-06-15 15:59:38 +00:00
Franck Nijhof b70666e5f8 Cache parsed card entities in hasConfigOrEntitiesChanged 2026-06-15 15:10:55 +00:00
4 changed files with 171 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
/**
* Memoizes a single-argument function keyed by its argument's object identity,
* caching results in a `WeakMap`.
*
* Unlike `memoizeOne`, this keeps every distinct argument cached, not just the
* most recent one. That suits pure derivations from stable config objects that
* are evaluated for many different configs and on a hot path, e.g. card
* `shouldUpdate` helpers running on every state change. Entries are garbage
* collected once the key object is no longer referenced.
*/
export const weakMemoize = <K extends object, V>(
func: (arg: K) => V
): ((arg: K) => V) => {
const cache = new WeakMap<K, V>();
return (arg) => {
if (!cache.has(arg)) {
cache.set(arg, func(arg));
}
return cache.get(arg)!;
};
};
+10 -1
View File
@@ -2,8 +2,17 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import { weakMemoize } from "../../../common/util/weak-memoize";
import { processConfigEntities } from "./process-config-entities";
// `hasConfigOrEntitiesChanged` runs in `shouldUpdate` on every state change, but
// the entities config only changes on (re)configure. Memoize the parsed result
// per config array so it is parsed once and reused instead of re-allocated on
// every state change.
const getConfigEntities = weakMemoize((entities: any[]) =>
processConfigEntities(entities, false)
);
export function hasConfigChanged(
element: any,
changedProps: PropertyValues
@@ -102,7 +111,7 @@ export function hasConfigOrEntitiesChanged(
const oldHass = changedProps.get("hass") as HomeAssistant;
const newHass = element.hass as HomeAssistant;
const entities = processConfigEntities(element._config!.entities, false);
const entities = getConfigEntities(element._config!.entities);
return entities.some((entity) => {
if (!("entity" in entity)) {
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { weakMemoize } from "../../../src/common/util/weak-memoize";
describe("weakMemoize", () => {
it("computes once per key and returns the cached result", () => {
const spy = vi.fn((arg: { id: number }) => ({ doubled: arg.id * 2 }));
const memoized = weakMemoize(spy);
const key = { id: 21 };
const first = memoized(key);
const second = memoized(key);
expect(first).toEqual({ doubled: 42 });
expect(second).toBe(first);
expect(spy).toHaveBeenCalledTimes(1);
});
it("computes separately for different keys", () => {
const spy = vi.fn((arg: { id: number }) => arg.id);
const memoized = weakMemoize(spy);
const a = { id: 1 };
const b = { id: 1 };
expect(memoized(a)).toBe(1);
expect(memoized(b)).toBe(1);
expect(spy).toHaveBeenCalledTimes(2);
});
it("caches falsy results without recomputing", () => {
const spy = vi.fn(() => undefined);
const memoized = weakMemoize(spy);
const key = {};
memoized(key);
memoized(key);
expect(spy).toHaveBeenCalledTimes(1);
});
it("gives each memoized function its own cache", () => {
const memoizedA = weakMemoize((arg: object) => arg);
const memoizedB = weakMemoize(() => "b");
const key = {};
expect(memoizedA(key)).toBe(key);
expect(memoizedB(key)).toBe("b");
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { hasConfigOrEntitiesChanged } from "../../../../src/panels/lovelace/common/has-changed";
import type { HomeAssistant } from "../../../../src/types";
const state = (entity_id: string, value: string) => ({
entity_id,
state: value,
attributes: {},
last_changed: "",
last_updated: "",
context: { id: "" },
});
// Shared references for everything hasConfigChanged compares, so that only
// the `states` differences drive the result in these tests.
const SHARED = {
connected: true,
entities: {},
config: { state: "RUNNING" },
themes: {},
locale: {},
localize: () => "",
formatEntityState: () => "",
formatEntityAttributeName: () => "",
formatEntityAttributeValue: () => "",
};
const createHass = (states: Record<string, any>): HomeAssistant =>
({ ...SHARED, states }) as unknown as HomeAssistant;
const changedProps = (oldHass: HomeAssistant) =>
new Map([["hass", oldHass]]) as any;
describe("hasConfigOrEntitiesChanged", () => {
it("returns true when a configured entity's state changes", () => {
const oldHass = createHass({
"light.a": state("light.a", "on"),
"light.b": state("light.b", "on"),
});
const newHass = createHass({
"light.a": state("light.a", "off"),
"light.b": oldHass.states["light.b"],
});
const element = {
hass: newHass,
_config: { entities: ["light.a", "light.b"] },
};
expect(hasConfigOrEntitiesChanged(element, changedProps(oldHass))).toBe(
true
);
});
it("returns false when only unconfigured entities change", () => {
const sharedA = state("light.a", "on");
const oldHass = createHass({
"light.a": sharedA,
"light.other": state("light.other", "on"),
});
const newHass = createHass({
"light.a": sharedA,
"light.other": state("light.other", "off"),
});
const element = {
hass: newHass,
_config: { entities: ["light.a"] },
};
expect(hasConfigOrEntitiesChanged(element, changedProps(oldHass))).toBe(
false
);
});
it("keeps detecting changes across repeated calls (cached config parsing)", () => {
const config = { entities: ["light.a"] };
const base = state("light.a", "on");
const hass1 = createHass({ "light.a": base });
// First call: config parsed and cached.
expect(
hasConfigOrEntitiesChanged(
{ hass: hass1, _config: config },
changedProps(hass1)
)
).toBe(false);
// Second call with a real change still detected (cache must not mask it).
const hass2 = createHass({ "light.a": state("light.a", "off") });
expect(
hasConfigOrEntitiesChanged(
{ hass: hass2, _config: config },
changedProps(hass1)
)
).toBe(true);
});
});