preact-homeassistant 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +104 -20
- package/dist/index.js +222 -158
- package/dist/index.js.map +1 -1
- package/package.json +32 -15
- package/src/HAContext.tsx +162 -120
- package/src/__tests__/cacheUtils.test.ts +23 -48
- package/src/__tests__/registerPreactCard.test.tsx +75 -0
- package/src/__tests__/testHelpers.tsx +11 -1
- package/src/__tests__/useCachedFetch.test.tsx +57 -33
- package/src/__tests__/{useMultiCalendarEvents.test.tsx → useCalendarEvents.test.tsx} +3 -4
- package/src/__tests__/useEntity.test.tsx +11 -17
- package/src/__tests__/useHassValue.test.tsx +99 -0
- package/src/cacheUtils.ts +12 -24
- package/src/index.ts +3 -2
- package/src/registerPreactCard.tsx +101 -32
package/src/HAContext.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { createContext } from 'preact';
|
|
|
2
2
|
import type { ComponentChildren } from 'preact';
|
|
3
3
|
import { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { type Cache, readCache, writeCache } from './cacheUtils';
|
|
6
6
|
import type {
|
|
7
7
|
CalendarEvent,
|
|
8
8
|
CalendarEventWithSource,
|
|
@@ -15,10 +15,19 @@ import type {
|
|
|
15
15
|
} from './types';
|
|
16
16
|
import { useCallbackStable } from './useCallbackStable';
|
|
17
17
|
|
|
18
|
+
type SubscribeToHass = (callback: () => void) => () => void;
|
|
19
|
+
|
|
20
|
+
// Default for providers that don't wire up hass-value notifications (Storybook,
|
|
21
|
+
// tests). useHassValue then simply returns its initial value and never updates.
|
|
22
|
+
const noopSubscribeToHass: SubscribeToHass = () => () => {};
|
|
23
|
+
|
|
18
24
|
interface HAStore {
|
|
19
|
-
hass: HomeAssistant | undefined;
|
|
20
25
|
getHass: () => HomeAssistant | undefined;
|
|
21
26
|
subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;
|
|
27
|
+
subscribeToHass: SubscribeToHass;
|
|
28
|
+
// Per-card cache (events, forecasts, entities). Owned by the provider so its
|
|
29
|
+
// lifetime is the card's — GC'd with the store when the card is torn down.
|
|
30
|
+
cache: Cache;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
const HAContext = createContext<HAStore | null>(null);
|
|
@@ -26,22 +35,38 @@ const HAContext = createContext<HAStore | null>(null);
|
|
|
26
35
|
interface HAProviderProps {
|
|
27
36
|
hass: HomeAssistant | undefined;
|
|
28
37
|
subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;
|
|
38
|
+
subscribeToHass?: SubscribeToHass;
|
|
39
|
+
// Optional injected cache (tests seed/inspect it); defaults to a fresh
|
|
40
|
+
// per-provider Map held stable across re-renders.
|
|
41
|
+
cache?: Cache;
|
|
29
42
|
children: ComponentChildren;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
|
-
export function HAProvider({
|
|
45
|
+
export function HAProvider({
|
|
46
|
+
hass,
|
|
47
|
+
subscribeToEntity,
|
|
48
|
+
subscribeToHass,
|
|
49
|
+
cache,
|
|
50
|
+
children,
|
|
51
|
+
}: HAProviderProps) {
|
|
33
52
|
const hassRef = useRef(hass);
|
|
34
53
|
hassRef.current = hass;
|
|
35
54
|
|
|
36
55
|
const getHass = useCallbackStable(() => hassRef.current);
|
|
37
56
|
|
|
57
|
+
const cacheRef = useRef<Cache>();
|
|
58
|
+
if (!cacheRef.current) cacheRef.current = cache ?? new Map();
|
|
59
|
+
|
|
60
|
+
const resolvedSubscribeToHass = subscribeToHass ?? noopSubscribeToHass;
|
|
61
|
+
|
|
38
62
|
const store = useMemo<HAStore>(
|
|
39
63
|
() => ({
|
|
40
|
-
hass: hassRef.current,
|
|
41
64
|
getHass,
|
|
42
65
|
subscribeToEntity,
|
|
66
|
+
subscribeToHass: resolvedSubscribeToHass,
|
|
67
|
+
cache: cacheRef.current!,
|
|
43
68
|
}),
|
|
44
|
-
[getHass, subscribeToEntity],
|
|
69
|
+
[getHass, subscribeToEntity, resolvedSubscribeToHass],
|
|
45
70
|
);
|
|
46
71
|
|
|
47
72
|
return <HAContext.Provider value={store}>{children}</HAContext.Provider>;
|
|
@@ -69,18 +94,18 @@ export function useEntity<T extends string>(entityId: T): EntityForId<T> | undef
|
|
|
69
94
|
const cacheKey = `entity:${entityId}`;
|
|
70
95
|
|
|
71
96
|
const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {
|
|
72
|
-
const current = store.
|
|
97
|
+
const current = store.getHass()?.states[entityId] as EntityForId<T> | undefined;
|
|
73
98
|
if (current) return current;
|
|
74
|
-
return
|
|
99
|
+
return readCache<EntityForId<T>>(store.cache, cacheKey);
|
|
75
100
|
});
|
|
76
101
|
|
|
77
102
|
useEffect(() => {
|
|
78
103
|
const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {
|
|
79
104
|
setEntity(newEntity as EntityForId<T>);
|
|
80
|
-
|
|
105
|
+
writeCache(store.cache, cacheKey, newEntity);
|
|
81
106
|
});
|
|
82
107
|
return unsubscribe;
|
|
83
|
-
}, [entityId, store.subscribeToEntity, cacheKey]);
|
|
108
|
+
}, [entityId, store.subscribeToEntity, store.cache, cacheKey]);
|
|
84
109
|
|
|
85
110
|
return entity;
|
|
86
111
|
}
|
|
@@ -94,6 +119,47 @@ export function useHass(): { getHass: () => HomeAssistant | undefined } {
|
|
|
94
119
|
return { getHass: store.getHass };
|
|
95
120
|
}
|
|
96
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Subscribe to a derived slice of the `hass` object (e.g. config, themes) and
|
|
124
|
+
* re-render only when that slice changes. Use this for non-entity values —
|
|
125
|
+
* entity state goes through `useEntity`. The selector runs on every hass update
|
|
126
|
+
* but only re-renders the consumer when `isEqual` reports a change, so it's
|
|
127
|
+
* cheap for rarely-changing values like config/themes.
|
|
128
|
+
*/
|
|
129
|
+
export function useHassValue<T>(
|
|
130
|
+
selector: (hass: HomeAssistant | undefined) => T,
|
|
131
|
+
isEqual: (a: T, b: T) => boolean = Object.is,
|
|
132
|
+
): T {
|
|
133
|
+
const store = useHAStore();
|
|
134
|
+
|
|
135
|
+
const selectorRef = useRef(selector);
|
|
136
|
+
selectorRef.current = selector;
|
|
137
|
+
const isEqualRef = useRef(isEqual);
|
|
138
|
+
isEqualRef.current = isEqual;
|
|
139
|
+
|
|
140
|
+
const [value, setValue] = useState<T>(() => selectorRef.current(store.getHass()));
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const unsubscribe = store.subscribeToHass(() => {
|
|
144
|
+
const next = selectorRef.current(store.getHass());
|
|
145
|
+
setValue((prev) => (isEqualRef.current(prev, next) ? prev : next));
|
|
146
|
+
});
|
|
147
|
+
return unsubscribe;
|
|
148
|
+
}, [store.subscribeToHass, store.getHass]);
|
|
149
|
+
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Re-renders when `hass.config` changes (units, latitude/longitude, etc.). */
|
|
154
|
+
export function useHassConfig(): HomeAssistant['config'] | undefined {
|
|
155
|
+
return useHassValue((hass) => hass?.config);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Re-renders when the active theme's dark mode flips. */
|
|
159
|
+
export function useDarkMode(): boolean {
|
|
160
|
+
return useHassValue((hass) => hass?.themes?.darkMode ?? false);
|
|
161
|
+
}
|
|
162
|
+
|
|
97
163
|
type ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(
|
|
98
164
|
service: S,
|
|
99
165
|
...args: ServicesForId<T>[S] extends undefined
|
|
@@ -140,11 +206,27 @@ export function useCachedFetch<T>(
|
|
|
140
206
|
fetcher: () => Promise<T>,
|
|
141
207
|
deps: unknown[],
|
|
142
208
|
): UseCachedFetchResult<T> {
|
|
143
|
-
const
|
|
209
|
+
const store = useHAStore();
|
|
210
|
+
const [data, setData] = useState<T | undefined>(() => readCache<T>(store.cache, cacheKey));
|
|
144
211
|
const [isFresh, setIsFresh] = useState(false);
|
|
145
212
|
const [isFetching, setIsFetching] = useState(false);
|
|
146
213
|
const [error, setError] = useState<Error | undefined>(undefined);
|
|
147
214
|
|
|
215
|
+
// Stale-while-revalidate, key-change aware. When `cacheKey` changes we swap to
|
|
216
|
+
// the new key's cached value synchronously (SWR hit) or keep the previously
|
|
217
|
+
// rendered data (keep-previous-data on a cold key) — never blanking to a
|
|
218
|
+
// loading state. The `deps` effect below issues the background refetch; the
|
|
219
|
+
// only `'loading'` state is a true cold start (nothing cached, nothing fetched).
|
|
220
|
+
const dataKeyRef = useRef(cacheKey);
|
|
221
|
+
if (cacheKey !== dataKeyRef.current) {
|
|
222
|
+
dataKeyRef.current = cacheKey;
|
|
223
|
+
setIsFresh(false);
|
|
224
|
+
setError(undefined);
|
|
225
|
+
const cached = readCache<T>(store.cache, cacheKey);
|
|
226
|
+
if (cached !== undefined) setData(cached);
|
|
227
|
+
// cache miss: leave `data` as-is (keep-previous-data)
|
|
228
|
+
}
|
|
229
|
+
|
|
148
230
|
const fetchIdRef = useRef(0);
|
|
149
231
|
|
|
150
232
|
const doFetch = useCallbackStable(async () => {
|
|
@@ -157,7 +239,7 @@ export function useCachedFetch<T>(
|
|
|
157
239
|
if (fetchId === fetchIdRef.current) {
|
|
158
240
|
setData(result);
|
|
159
241
|
setIsFresh(true);
|
|
160
|
-
|
|
242
|
+
writeCache(store.cache, cacheKey, result);
|
|
161
243
|
}
|
|
162
244
|
} catch (err) {
|
|
163
245
|
if (fetchId === fetchIdRef.current) {
|
|
@@ -172,7 +254,6 @@ export function useCachedFetch<T>(
|
|
|
172
254
|
|
|
173
255
|
useEffect(() => {
|
|
174
256
|
doFetch();
|
|
175
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
257
|
}, deps);
|
|
177
258
|
|
|
178
259
|
const status: FetchStatus = useMemo(() => {
|
|
@@ -186,87 +267,77 @@ export function useCachedFetch<T>(
|
|
|
186
267
|
}
|
|
187
268
|
|
|
188
269
|
interface UseCalendarEventsResult {
|
|
189
|
-
events:
|
|
190
|
-
|
|
270
|
+
events: CalendarEventWithSource[] | undefined;
|
|
271
|
+
status: FetchStatus;
|
|
191
272
|
error: Error | undefined;
|
|
192
273
|
refetch: () => void;
|
|
274
|
+
/**
|
|
275
|
+
* Warm the cache for an arbitrary range (e.g. adjacent months) without
|
|
276
|
+
* touching component state. Best-effort: skips ranges already cached and
|
|
277
|
+
* swallows failures.
|
|
278
|
+
*/
|
|
279
|
+
prefetch: (range: { start: Date; end: Date }) => void;
|
|
193
280
|
}
|
|
194
281
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
): UseCalendarEventsResult {
|
|
202
|
-
const { getHass } = useHass();
|
|
203
|
-
const [events, setEvents] = useState<CalendarEvent[] | undefined>(undefined);
|
|
204
|
-
const [loading, setLoading] = useState(false);
|
|
205
|
-
const [error, setError] = useState<Error | undefined>(undefined);
|
|
206
|
-
|
|
207
|
-
const fetchIdRef = useRef(0);
|
|
208
|
-
|
|
209
|
-
const fetchEvents = useCallbackStable(async () => {
|
|
210
|
-
const hass = getHass();
|
|
211
|
-
if (!hass?.connection) {
|
|
212
|
-
setError(new Error('Home Assistant connection not available'));
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const fetchId = ++fetchIdRef.current;
|
|
217
|
-
setLoading(true);
|
|
218
|
-
setError(undefined);
|
|
282
|
+
function calendarEventsCacheKey(
|
|
283
|
+
entityIds: `calendar.${string}`[],
|
|
284
|
+
range: { start: Date; end: Date },
|
|
285
|
+
): string {
|
|
286
|
+
return `events:${entityIds.join(',')}:${range.start.getTime()}-${range.end.getTime()}`;
|
|
287
|
+
}
|
|
219
288
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
target: { entity_id: entityId },
|
|
232
|
-
return_response: true,
|
|
233
|
-
});
|
|
289
|
+
async function fetchCalendarRange(
|
|
290
|
+
hass: HomeAssistant | undefined,
|
|
291
|
+
entityIds: `calendar.${string}`[],
|
|
292
|
+
range: { start: Date; end: Date },
|
|
293
|
+
): Promise<CalendarEventWithSource[]> {
|
|
294
|
+
if (!hass?.connection) {
|
|
295
|
+
throw new Error('Home Assistant connection not available');
|
|
296
|
+
}
|
|
297
|
+
if (entityIds.length === 0) {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
234
300
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
301
|
+
const results = await Promise.all(
|
|
302
|
+
entityIds.map(async (entityId) => {
|
|
303
|
+
try {
|
|
304
|
+
const result = await hass.connection.sendMessagePromise<{
|
|
305
|
+
response: { [key: string]: { events: CalendarEvent[] } };
|
|
306
|
+
}>({
|
|
307
|
+
type: 'call_service',
|
|
308
|
+
domain: 'calendar',
|
|
309
|
+
service: 'get_events',
|
|
310
|
+
service_data: {
|
|
311
|
+
start_date_time: range.start.toISOString(),
|
|
312
|
+
end_date_time: range.end.toISOString(),
|
|
313
|
+
},
|
|
314
|
+
target: { entity_id: entityId },
|
|
315
|
+
return_response: true,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const calendarEvents = result.response?.[entityId]?.events ?? [];
|
|
319
|
+
return calendarEvents.map(
|
|
320
|
+
(event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),
|
|
321
|
+
);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error(`Failed to fetch events for ${entityId}:`, err);
|
|
324
|
+
return [];
|
|
239
325
|
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
243
|
-
setLoading(false);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
useEffect(() => {
|
|
249
|
-
fetchEvents();
|
|
250
|
-
}, [entityId, options.start.getTime(), options.end.getTime(), fetchEvents]);
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
251
328
|
|
|
252
|
-
return
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
interface UseMultiCalendarEventsResult {
|
|
256
|
-
events: CalendarEventWithSource[] | undefined;
|
|
257
|
-
status: FetchStatus;
|
|
258
|
-
error: Error | undefined;
|
|
259
|
-
refetch: () => void;
|
|
329
|
+
return results.flat();
|
|
260
330
|
}
|
|
261
331
|
|
|
262
332
|
/**
|
|
263
|
-
* Fetch events from
|
|
264
|
-
* caching. Events are tagged
|
|
333
|
+
* Fetch events from one or more calendars for a date range, with in-memory
|
|
334
|
+
* (per-card) caching and stale-while-revalidate behavior. Events are tagged
|
|
335
|
+
* with their source calendar ID. Returns `prefetch` to warm adjacent ranges.
|
|
265
336
|
*/
|
|
266
|
-
export function
|
|
337
|
+
export function useCalendarEvents(
|
|
267
338
|
entityIds: `calendar.${string}`[],
|
|
268
339
|
options: { start: Date; end: Date },
|
|
269
|
-
):
|
|
340
|
+
): UseCalendarEventsResult {
|
|
270
341
|
const store = useHAStore();
|
|
271
342
|
const { getHass } = useHass();
|
|
272
343
|
|
|
@@ -276,46 +347,7 @@ export function useMultiCalendarEvents(
|
|
|
276
347
|
const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;
|
|
277
348
|
const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;
|
|
278
349
|
|
|
279
|
-
const fetcher = useCallbackStable(
|
|
280
|
-
const hass = getHass();
|
|
281
|
-
if (!hass?.connection) {
|
|
282
|
-
throw new Error('Home Assistant connection not available');
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (entityIds.length === 0) {
|
|
286
|
-
return [];
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const results = await Promise.all(
|
|
290
|
-
entityIds.map(async (entityId) => {
|
|
291
|
-
try {
|
|
292
|
-
const result = await hass.connection.sendMessagePromise<{
|
|
293
|
-
response: { [key: string]: { events: CalendarEvent[] } };
|
|
294
|
-
}>({
|
|
295
|
-
type: 'call_service',
|
|
296
|
-
domain: 'calendar',
|
|
297
|
-
service: 'get_events',
|
|
298
|
-
service_data: {
|
|
299
|
-
start_date_time: options.start.toISOString(),
|
|
300
|
-
end_date_time: options.end.toISOString(),
|
|
301
|
-
},
|
|
302
|
-
target: { entity_id: entityId },
|
|
303
|
-
return_response: true,
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
const calendarEvents = result.response?.[entityId]?.events ?? [];
|
|
307
|
-
return calendarEvents.map(
|
|
308
|
-
(event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),
|
|
309
|
-
);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
console.error(`Failed to fetch events for ${entityId}:`, err);
|
|
312
|
-
return [];
|
|
313
|
-
}
|
|
314
|
-
}),
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
return results.flat();
|
|
318
|
-
});
|
|
350
|
+
const fetcher = useCallbackStable(() => fetchCalendarRange(getHass(), entityIds, options));
|
|
319
351
|
|
|
320
352
|
const {
|
|
321
353
|
data: events,
|
|
@@ -324,6 +356,16 @@ export function useMultiCalendarEvents(
|
|
|
324
356
|
refetch,
|
|
325
357
|
} = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);
|
|
326
358
|
|
|
359
|
+
const prefetch = useCallbackStable((range: { start: Date; end: Date }) => {
|
|
360
|
+
const key = calendarEventsCacheKey(entityIds, range);
|
|
361
|
+
if (store.cache.has(key)) return; // already warm
|
|
362
|
+
fetchCalendarRange(getHass(), entityIds, range)
|
|
363
|
+
.then((result) => writeCache(store.cache, key, result))
|
|
364
|
+
.catch(() => {
|
|
365
|
+
// best-effort prefetch; ignore failures
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
327
369
|
const debouncedRefetch = useCallbackStable(() => {
|
|
328
370
|
if (debounceTimerRef.current) {
|
|
329
371
|
clearTimeout(debounceTimerRef.current);
|
|
@@ -343,7 +385,7 @@ export function useMultiCalendarEvents(
|
|
|
343
385
|
};
|
|
344
386
|
}, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);
|
|
345
387
|
|
|
346
|
-
return { events, status, error, refetch };
|
|
388
|
+
return { events, status, error, refetch, prefetch };
|
|
347
389
|
}
|
|
348
390
|
|
|
349
391
|
interface UseWeatherForecastResult {
|
|
@@ -1,61 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type Cache, readCache, writeCache } from '../cacheUtils';
|
|
3
3
|
|
|
4
4
|
describe('cacheUtils', () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
it('round-trips data through save and load', () => {
|
|
10
|
-
saveToCache('test-key', { foo: 'bar' });
|
|
11
|
-
expect(loadFromCache('test-key')).toEqual({ foo: 'bar' });
|
|
5
|
+
it('round-trips data through write and read', () => {
|
|
6
|
+
const cache: Cache = new Map();
|
|
7
|
+
writeCache(cache, 'test-key', { foo: 'bar' });
|
|
8
|
+
expect(readCache(cache, 'test-key')).toEqual({ foo: 'bar' });
|
|
12
9
|
});
|
|
13
10
|
|
|
14
11
|
it('returns undefined for missing keys', () => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
it('returns undefined for expired entries', () => {
|
|
19
|
-
saveToCache('old', 'data');
|
|
20
|
-
|
|
21
|
-
// Patch the stored timestamp to 25 hours ago
|
|
22
|
-
const raw = localStorage.getItem('preact-ha:old')!;
|
|
23
|
-
const entry = JSON.parse(raw);
|
|
24
|
-
entry.timestamp = Date.now() - 25 * 60 * 60 * 1000;
|
|
25
|
-
localStorage.setItem('preact-ha:old', JSON.stringify(entry));
|
|
26
|
-
|
|
27
|
-
expect(loadFromCache('old')).toBeUndefined();
|
|
12
|
+
const cache: Cache = new Map();
|
|
13
|
+
expect(readCache(cache, 'nonexistent')).toBeUndefined();
|
|
28
14
|
});
|
|
29
15
|
|
|
30
|
-
it('
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
entry.timestamp = Date.now() - 25 * 60 * 60 * 1000;
|
|
36
|
-
localStorage.setItem('preact-ha:old', JSON.stringify(entry));
|
|
37
|
-
|
|
38
|
-
loadFromCache('old');
|
|
39
|
-
expect(localStorage.getItem('preact-ha:old')).toBeNull();
|
|
16
|
+
it('overwrites an existing entry', () => {
|
|
17
|
+
const cache: Cache = new Map();
|
|
18
|
+
writeCache(cache, 'k', 1);
|
|
19
|
+
writeCache(cache, 'k', 2);
|
|
20
|
+
expect(readCache(cache, 'k')).toBe(2);
|
|
40
21
|
});
|
|
41
22
|
|
|
42
|
-
it('
|
|
43
|
-
|
|
44
|
-
|
|
23
|
+
it('isolates entries between separate cache maps', () => {
|
|
24
|
+
const a: Cache = new Map();
|
|
25
|
+
const b: Cache = new Map();
|
|
26
|
+
writeCache(a, 'k', 'a-value');
|
|
27
|
+
expect(readCache(b, 'k')).toBeUndefined();
|
|
45
28
|
});
|
|
46
29
|
|
|
47
|
-
it('
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// Should not throw
|
|
55
|
-
saveToCache('key', 'value');
|
|
56
|
-
expect(warnSpy).toHaveBeenCalled();
|
|
57
|
-
|
|
58
|
-
localStorage.setItem = originalSetItem;
|
|
59
|
-
vi.restoreAllMocks();
|
|
30
|
+
it('stores undefined data without conflating it with a missing key', () => {
|
|
31
|
+
const cache: Cache = new Map();
|
|
32
|
+
writeCache(cache, 'k', undefined);
|
|
33
|
+
expect(cache.has('k')).toBe(true);
|
|
34
|
+
expect(readCache(cache, 'k')).toBeUndefined();
|
|
60
35
|
});
|
|
61
36
|
});
|
|
@@ -216,4 +216,79 @@ describe('registerPreactCard', () => {
|
|
|
216
216
|
const CardClass = customElements.get(type) as any;
|
|
217
217
|
expect(CardClass.getStubConfig()).toEqual({ entities: [] });
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
it('tears down the Preact tree after the grace period when removed', () => {
|
|
221
|
+
vi.useFakeTimers();
|
|
222
|
+
const type = uniqueType();
|
|
223
|
+
registerPreactCard({ type, name: 'Test', description: 'Test', Component: TestComponent });
|
|
224
|
+
|
|
225
|
+
const card = document.createElement(type) as any;
|
|
226
|
+
document.body.appendChild(card);
|
|
227
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
228
|
+
card.hass = makeHass({ 'sensor.temp': { state: '72' } });
|
|
229
|
+
expect(card.shadowRoot.textContent).toContain('test');
|
|
230
|
+
|
|
231
|
+
document.body.removeChild(card);
|
|
232
|
+
|
|
233
|
+
// Within the grace window: still mounted.
|
|
234
|
+
vi.advanceTimersByTime(4000);
|
|
235
|
+
expect(card.shadowRoot.textContent).toContain('test');
|
|
236
|
+
|
|
237
|
+
// Past the grace window: torn down (render(null) clears the shadow root).
|
|
238
|
+
vi.advanceTimersByTime(2000);
|
|
239
|
+
expect(card.shadowRoot.textContent).toBe('');
|
|
240
|
+
|
|
241
|
+
vi.useRealTimers();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('cancels teardown when reconnected within the grace period', () => {
|
|
245
|
+
vi.useFakeTimers();
|
|
246
|
+
const type = uniqueType();
|
|
247
|
+
registerPreactCard({ type, name: 'Test', description: 'Test', Component: TestComponent });
|
|
248
|
+
|
|
249
|
+
const card = document.createElement(type) as any;
|
|
250
|
+
document.body.appendChild(card);
|
|
251
|
+
card.setConfig({ entities: ['sensor.temp'] });
|
|
252
|
+
card.hass = makeHass({ 'sensor.temp': { state: '72' } });
|
|
253
|
+
|
|
254
|
+
document.body.removeChild(card);
|
|
255
|
+
vi.advanceTimersByTime(4000); // still within grace
|
|
256
|
+
document.body.appendChild(card); // reconnect cancels the scheduled teardown
|
|
257
|
+
|
|
258
|
+
vi.advanceTimersByTime(4000); // 8000ms total since removal — would have torn down
|
|
259
|
+
expect(card.shadowRoot.textContent).toContain('test');
|
|
260
|
+
|
|
261
|
+
vi.useRealTimers();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('re-renders the editor on every hass update', () => {
|
|
265
|
+
const type = uniqueType();
|
|
266
|
+
let editorRenders = 0;
|
|
267
|
+
function TestEditor() {
|
|
268
|
+
editorRenders++;
|
|
269
|
+
return <div>editor</div>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
registerPreactCard({
|
|
273
|
+
type,
|
|
274
|
+
name: 'Test',
|
|
275
|
+
description: 'Test',
|
|
276
|
+
Component: TestComponent,
|
|
277
|
+
ConfigComponent: TestEditor,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const editor = document.createElement(`${type}-editor`) as any;
|
|
281
|
+
document.body.appendChild(editor);
|
|
282
|
+
editor.setConfig({ entities: [] });
|
|
283
|
+
|
|
284
|
+
editor.hass = makeHass({});
|
|
285
|
+
const afterFirstHass = editorRenders;
|
|
286
|
+
expect(afterFirstHass).toBeGreaterThan(0);
|
|
287
|
+
|
|
288
|
+
editor.hass = makeHass({ 'sensor.x': { state: '1' } });
|
|
289
|
+
expect(editorRenders).toBeGreaterThan(afterFirstHass);
|
|
290
|
+
|
|
291
|
+
editor.hass = makeHass({ 'sensor.x': { state: '2' } });
|
|
292
|
+
expect(editorRenders).toBeGreaterThan(afterFirstHass + 1);
|
|
293
|
+
});
|
|
219
294
|
});
|
|
@@ -49,18 +49,28 @@ export function createMockSubscribe() {
|
|
|
49
49
|
interface RenderWithHAOptions extends Omit<RenderOptions, 'wrapper'> {
|
|
50
50
|
hass?: HomeAssistant;
|
|
51
51
|
subscribeFn?: MockSubscribeFn;
|
|
52
|
+
subscribeToHass?: (cb: () => void) => () => void;
|
|
53
|
+
// Inject a per-card cache so tests can seed/inspect it.
|
|
54
|
+
cache?: Map<string, { data: unknown }>;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
export function renderWithHA(ui: ComponentChildren, options: RenderWithHAOptions = {}) {
|
|
55
58
|
const {
|
|
56
59
|
hass = makeHass(),
|
|
57
60
|
subscribeFn = createMockSubscribe().subscribe,
|
|
61
|
+
subscribeToHass,
|
|
62
|
+
cache,
|
|
58
63
|
...renderOptions
|
|
59
64
|
} = options;
|
|
60
65
|
|
|
61
66
|
function Wrapper({ children }: { children: ComponentChildren }) {
|
|
62
67
|
return (
|
|
63
|
-
<HAProvider
|
|
68
|
+
<HAProvider
|
|
69
|
+
hass={hass}
|
|
70
|
+
subscribeToEntity={subscribeFn}
|
|
71
|
+
subscribeToHass={subscribeToHass}
|
|
72
|
+
cache={cache}
|
|
73
|
+
>
|
|
64
74
|
{children}
|
|
65
75
|
</HAProvider>
|
|
66
76
|
);
|