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
|
@@ -2,9 +2,11 @@ import { act, screen, waitFor } from '@testing-library/preact';
|
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { useCachedFetch } from '../HAContext';
|
|
4
4
|
import type { FetchStatus } from '../types';
|
|
5
|
-
import {
|
|
5
|
+
import { renderWithHA } from './testHelpers';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Every status seen across renders, so we can assert the SWR invariant
|
|
8
|
+
// (never 'loading' when data is available).
|
|
9
|
+
let statuses: FetchStatus[] = [];
|
|
8
10
|
|
|
9
11
|
function FetchDisplay({
|
|
10
12
|
cacheKey,
|
|
@@ -16,7 +18,7 @@ function FetchDisplay({
|
|
|
16
18
|
deps: unknown[];
|
|
17
19
|
}) {
|
|
18
20
|
const { data, status, error } = useCachedFetch(cacheKey, fetcher, deps);
|
|
19
|
-
|
|
21
|
+
statuses.push(status);
|
|
20
22
|
return (
|
|
21
23
|
<div>
|
|
22
24
|
<span data-testid="data">{JSON.stringify(data) ?? 'undefined'}</span>
|
|
@@ -28,31 +30,25 @@ function FetchDisplay({
|
|
|
28
30
|
|
|
29
31
|
describe('useCachedFetch', () => {
|
|
30
32
|
beforeEach(() => {
|
|
31
|
-
|
|
33
|
+
statuses = [];
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
it('transitions from loading to ready on successful fetch', async () => {
|
|
36
|
+
it('transitions from loading to ready on successful fetch (cold start)', async () => {
|
|
35
37
|
const fetcher = vi.fn().mockResolvedValue({ temp: 72 });
|
|
36
|
-
const { subscribe } = createMockSubscribe();
|
|
37
38
|
|
|
38
|
-
renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]}
|
|
39
|
-
subscribeFn: subscribe,
|
|
40
|
-
});
|
|
39
|
+
renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />);
|
|
41
40
|
|
|
42
41
|
await waitFor(() => {
|
|
43
42
|
expect(screen.getByTestId('status').textContent).toBe('ready');
|
|
44
43
|
});
|
|
45
44
|
|
|
46
45
|
expect(screen.getByTestId('data').textContent).toBe('{"temp":72}');
|
|
46
|
+
// Cold start is the only time 'loading' is allowed.
|
|
47
|
+
expect(statuses).toContain('loading');
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
it('shows cached data while refreshing', async () => {
|
|
50
|
-
|
|
51
|
-
const entry = {
|
|
52
|
-
data: { temp: 68 },
|
|
53
|
-
timestamp: Date.now(),
|
|
54
|
-
};
|
|
55
|
-
localStorage.setItem('preact-ha:test', JSON.stringify(entry));
|
|
51
|
+
const cache = new Map<string, { data: unknown }>([['test', { data: { temp: 68 } }]]);
|
|
56
52
|
|
|
57
53
|
let resolve: (v: any) => void;
|
|
58
54
|
const fetcher = vi.fn().mockImplementation(
|
|
@@ -61,16 +57,13 @@ describe('useCachedFetch', () => {
|
|
|
61
57
|
resolve = r;
|
|
62
58
|
}),
|
|
63
59
|
);
|
|
64
|
-
const { subscribe } = createMockSubscribe();
|
|
65
60
|
|
|
66
|
-
renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />, {
|
|
67
|
-
subscribeFn: subscribe,
|
|
68
|
-
});
|
|
61
|
+
renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />, { cache });
|
|
69
62
|
|
|
70
|
-
//
|
|
63
|
+
// Cached data shown immediately; no loading flash.
|
|
71
64
|
expect(screen.getByTestId('data').textContent).toBe('{"temp":68}');
|
|
65
|
+
expect(statuses).not.toContain('loading');
|
|
72
66
|
|
|
73
|
-
// Resolve the fetch
|
|
74
67
|
await act(async () => {
|
|
75
68
|
resolve!({ temp: 75 });
|
|
76
69
|
});
|
|
@@ -84,32 +77,27 @@ describe('useCachedFetch', () => {
|
|
|
84
77
|
|
|
85
78
|
it('reports errors from failed fetches', async () => {
|
|
86
79
|
const fetcher = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
87
|
-
const { subscribe } = createMockSubscribe();
|
|
88
80
|
|
|
89
|
-
renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]}
|
|
90
|
-
subscribeFn: subscribe,
|
|
91
|
-
});
|
|
81
|
+
renderWithHA(<FetchDisplay cacheKey="test" fetcher={fetcher} deps={[]} />);
|
|
92
82
|
|
|
93
83
|
await waitFor(() => {
|
|
94
84
|
expect(screen.getByTestId('error').textContent).toBe('Network error');
|
|
95
85
|
});
|
|
96
86
|
});
|
|
97
87
|
|
|
98
|
-
it('
|
|
88
|
+
it('writes fetched data to the cache', async () => {
|
|
89
|
+
const cache = new Map<string, { data: unknown }>();
|
|
99
90
|
const fetcher = vi.fn().mockResolvedValue({ temp: 72 });
|
|
100
|
-
const { subscribe } = createMockSubscribe();
|
|
101
91
|
|
|
102
92
|
renderWithHA(<FetchDisplay cacheKey="cache-write-test" fetcher={fetcher} deps={[]} />, {
|
|
103
|
-
|
|
93
|
+
cache,
|
|
104
94
|
});
|
|
105
95
|
|
|
106
96
|
await waitFor(() => {
|
|
107
97
|
expect(screen.getByTestId('status').textContent).toBe('ready');
|
|
108
98
|
});
|
|
109
99
|
|
|
110
|
-
|
|
111
|
-
expect(cached).toBeTruthy();
|
|
112
|
-
expect(JSON.parse(cached!).data).toEqual({ temp: 72 });
|
|
100
|
+
expect(cache.get('cache-write-test')).toEqual({ data: { temp: 72 } });
|
|
113
101
|
});
|
|
114
102
|
|
|
115
103
|
it('ignores stale fetch results', async () => {
|
|
@@ -120,11 +108,9 @@ describe('useCachedFetch', () => {
|
|
|
120
108
|
resolvers.push(r);
|
|
121
109
|
}),
|
|
122
110
|
);
|
|
123
|
-
const { subscribe } = createMockSubscribe();
|
|
124
111
|
|
|
125
112
|
const { rerender } = renderWithHA(
|
|
126
113
|
<FetchDisplay cacheKey="test" fetcher={fetcher} deps={['a']} />,
|
|
127
|
-
{ subscribeFn: subscribe },
|
|
128
114
|
);
|
|
129
115
|
|
|
130
116
|
// Trigger a second fetch by changing deps
|
|
@@ -144,4 +130,42 @@ describe('useCachedFetch', () => {
|
|
|
144
130
|
expect(screen.getByTestId('data').textContent).toBe('{"temp":"fresh"}');
|
|
145
131
|
});
|
|
146
132
|
});
|
|
133
|
+
|
|
134
|
+
it('swaps to the new key cached data instantly on key change, never loading', () => {
|
|
135
|
+
const cache = new Map<string, { data: unknown }>([
|
|
136
|
+
['a', { data: { v: 'A' } }],
|
|
137
|
+
['b', { data: { v: 'B' } }],
|
|
138
|
+
]);
|
|
139
|
+
// Fetcher never resolves, so only cache-driven state is observed.
|
|
140
|
+
const fetcher = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
141
|
+
|
|
142
|
+
const { rerender } = renderWithHA(
|
|
143
|
+
<FetchDisplay cacheKey="a" fetcher={fetcher} deps={['a']} />,
|
|
144
|
+
{ cache },
|
|
145
|
+
);
|
|
146
|
+
expect(screen.getByTestId('data').textContent).toBe('{"v":"A"}');
|
|
147
|
+
|
|
148
|
+
rerender(<FetchDisplay cacheKey="b" fetcher={fetcher} deps={['b']} />);
|
|
149
|
+
|
|
150
|
+
// Synchronous swap to the new key's cached value, no loading flash.
|
|
151
|
+
expect(screen.getByTestId('data').textContent).toBe('{"v":"B"}');
|
|
152
|
+
expect(screen.getByTestId('status').textContent).toBe('cached');
|
|
153
|
+
expect(statuses).not.toContain('loading');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps the previous data on a cold key change (keep-previous-data)', () => {
|
|
157
|
+
const cache = new Map<string, { data: unknown }>([['a', { data: { v: 'A' } }]]);
|
|
158
|
+
const fetcher = vi.fn().mockImplementation(() => new Promise(() => {}));
|
|
159
|
+
|
|
160
|
+
const { rerender } = renderWithHA(
|
|
161
|
+
<FetchDisplay cacheKey="a" fetcher={fetcher} deps={['a']} />,
|
|
162
|
+
{ cache },
|
|
163
|
+
);
|
|
164
|
+
expect(screen.getByTestId('data').textContent).toBe('{"v":"A"}');
|
|
165
|
+
|
|
166
|
+
// 'b' is not cached → keep showing A while the (pending) fetch runs.
|
|
167
|
+
rerender(<FetchDisplay cacheKey="b" fetcher={fetcher} deps={['b']} />);
|
|
168
|
+
expect(screen.getByTestId('data').textContent).toBe('{"v":"A"}');
|
|
169
|
+
expect(statuses).not.toContain('loading');
|
|
170
|
+
});
|
|
147
171
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { act, screen, waitFor } from '@testing-library/preact';
|
|
2
2
|
import { render } from '@testing-library/preact';
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import {
|
|
4
|
+
import { useCalendarEvents } from '../HAContext';
|
|
5
5
|
import { HAProvider } from '../HAContext';
|
|
6
6
|
import type { FetchStatus } from '../types';
|
|
7
7
|
import type { CalendarEventWithSource } from '../types';
|
|
@@ -16,7 +16,7 @@ function CalendarDisplay({
|
|
|
16
16
|
start: Date;
|
|
17
17
|
end: Date;
|
|
18
18
|
}) {
|
|
19
|
-
const { events, status, error } =
|
|
19
|
+
const { events, status, error } = useCalendarEvents(entityIds, {
|
|
20
20
|
start,
|
|
21
21
|
end,
|
|
22
22
|
});
|
|
@@ -33,12 +33,11 @@ function CalendarDisplay({
|
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
describe('
|
|
36
|
+
describe('useCalendarEvents', () => {
|
|
37
37
|
const start = new Date('2025-01-01');
|
|
38
38
|
const end = new Date('2025-01-31');
|
|
39
39
|
|
|
40
40
|
beforeEach(() => {
|
|
41
|
-
localStorage.clear();
|
|
42
41
|
vi.restoreAllMocks();
|
|
43
42
|
});
|
|
44
43
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { act, screen } from '@testing-library/preact';
|
|
2
2
|
import { useRef } from 'preact/hooks';
|
|
3
|
-
import {
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { useEntity } from '../HAContext';
|
|
5
5
|
import { createMockSubscribe, makeHass, renderWithHA } from './testHelpers';
|
|
6
6
|
|
|
@@ -18,10 +18,6 @@ function EntityDisplay({ entityId }: { entityId: string }) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
describe('useEntity', () => {
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
localStorage.clear();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
21
|
it('returns the initial entity state from hass', () => {
|
|
26
22
|
const hass = makeHass({
|
|
27
23
|
'sensor.temp': { entity_id: 'sensor.temp', state: '72' },
|
|
@@ -64,37 +60,35 @@ describe('useEntity', () => {
|
|
|
64
60
|
expect(screen.getByTestId('state').textContent).toBe('75');
|
|
65
61
|
});
|
|
66
62
|
|
|
67
|
-
it('caches entity to
|
|
63
|
+
it('caches the entity to the per-card cache on update', async () => {
|
|
64
|
+
const cache = new Map<string, { data: unknown }>();
|
|
68
65
|
const { subscribe, notify } = createMockSubscribe();
|
|
69
66
|
|
|
70
67
|
renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
|
|
71
68
|
hass: makeHass({}),
|
|
72
69
|
subscribeFn: subscribe,
|
|
70
|
+
cache,
|
|
73
71
|
});
|
|
74
72
|
|
|
75
73
|
await act(() => {
|
|
76
74
|
notify('sensor.temp', { entity_id: 'sensor.temp', state: '75' });
|
|
77
75
|
});
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(parsed.data.state).toBe('75');
|
|
77
|
+
expect(cache.get('entity:sensor.temp')).toEqual({
|
|
78
|
+
data: { entity_id: 'sensor.temp', state: '75' },
|
|
79
|
+
});
|
|
83
80
|
});
|
|
84
81
|
|
|
85
82
|
it('loads from cache when hass has no state', () => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
timestamp: Date.now(),
|
|
90
|
-
};
|
|
91
|
-
localStorage.setItem('preact-ha:entity:sensor.temp', JSON.stringify(entry));
|
|
92
|
-
|
|
83
|
+
const cache = new Map<string, { data: unknown }>([
|
|
84
|
+
['entity:sensor.temp', { data: { entity_id: 'sensor.temp', state: '68' } }],
|
|
85
|
+
]);
|
|
93
86
|
const { subscribe } = createMockSubscribe();
|
|
94
87
|
|
|
95
88
|
renderWithHA(<EntityDisplay entityId="sensor.temp" />, {
|
|
96
89
|
hass: makeHass({}),
|
|
97
90
|
subscribeFn: subscribe,
|
|
91
|
+
cache,
|
|
98
92
|
});
|
|
99
93
|
|
|
100
94
|
expect(screen.getByTestId('state').textContent).toBe('68');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/preact';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { HAProvider, useDarkMode } from '../HAContext';
|
|
4
|
+
import type { HomeAssistant } from '../types';
|
|
5
|
+
|
|
6
|
+
function makeHass(darkMode: boolean): HomeAssistant {
|
|
7
|
+
return {
|
|
8
|
+
states: {},
|
|
9
|
+
config: {} as any,
|
|
10
|
+
services: {} as any,
|
|
11
|
+
connection: {} as any,
|
|
12
|
+
callService: () => {},
|
|
13
|
+
themes: { darkMode },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Controllable hass-change subscription.
|
|
18
|
+
function makeHassSubscription() {
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
return {
|
|
21
|
+
subscribeToHass: (cb: () => void) => {
|
|
22
|
+
listeners.add(cb);
|
|
23
|
+
return () => listeners.delete(cb);
|
|
24
|
+
},
|
|
25
|
+
notify: () => listeners.forEach((cb) => cb()),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let renderCount = 0;
|
|
30
|
+
function DarkModeProbe() {
|
|
31
|
+
renderCount++;
|
|
32
|
+
const dark = useDarkMode();
|
|
33
|
+
return <span data-testid="dark">{String(dark)}</span>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('useHassValue / useDarkMode', () => {
|
|
37
|
+
it('returns the initial selected value', () => {
|
|
38
|
+
const { subscribeToHass } = makeHassSubscription();
|
|
39
|
+
render(
|
|
40
|
+
<HAProvider
|
|
41
|
+
hass={makeHass(true)}
|
|
42
|
+
subscribeToEntity={() => () => {}}
|
|
43
|
+
subscribeToHass={subscribeToHass}
|
|
44
|
+
>
|
|
45
|
+
<DarkModeProbe />
|
|
46
|
+
</HAProvider>,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByTestId('dark').textContent).toBe('true');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not re-render when the selected slice is unchanged', () => {
|
|
52
|
+
const { subscribeToHass, notify } = makeHassSubscription();
|
|
53
|
+
renderCount = 0;
|
|
54
|
+
render(
|
|
55
|
+
<HAProvider
|
|
56
|
+
hass={makeHass(false)}
|
|
57
|
+
subscribeToEntity={() => () => {}}
|
|
58
|
+
subscribeToHass={subscribeToHass}
|
|
59
|
+
>
|
|
60
|
+
<DarkModeProbe />
|
|
61
|
+
</HAProvider>,
|
|
62
|
+
);
|
|
63
|
+
const afterMount = renderCount;
|
|
64
|
+
|
|
65
|
+
// hass pushes arrive but darkMode is still false → no re-render.
|
|
66
|
+
act(() => {
|
|
67
|
+
notify();
|
|
68
|
+
notify();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(renderCount).toBe(afterMount);
|
|
72
|
+
expect(screen.getByTestId('dark').textContent).toBe('false');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('updates when the selected slice changes', () => {
|
|
76
|
+
const { subscribeToHass, notify } = makeHassSubscription();
|
|
77
|
+
let hass = makeHass(false);
|
|
78
|
+
|
|
79
|
+
const { rerender } = render(
|
|
80
|
+
<HAProvider hass={hass} subscribeToEntity={() => () => {}} subscribeToHass={subscribeToHass}>
|
|
81
|
+
<DarkModeProbe />
|
|
82
|
+
</HAProvider>,
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByTestId('dark').textContent).toBe('false');
|
|
85
|
+
|
|
86
|
+
// New hass with darkMode flipped, then a hass-change notification.
|
|
87
|
+
hass = makeHass(true);
|
|
88
|
+
rerender(
|
|
89
|
+
<HAProvider hass={hass} subscribeToEntity={() => () => {}} subscribeToHass={subscribeToHass}>
|
|
90
|
+
<DarkModeProbe />
|
|
91
|
+
</HAProvider>,
|
|
92
|
+
);
|
|
93
|
+
act(() => {
|
|
94
|
+
notify();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(screen.getByTestId('dark').textContent).toBe('true');
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/cacheUtils.ts
CHANGED
|
@@ -1,32 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// In-memory cache helpers. The cache Map is owned per-card by the HAProvider
|
|
2
|
+
// store (see HAContext), so its lifetime matches the card and it is
|
|
3
|
+
// garbage-collected when the card is torn down. There is intentionally no
|
|
4
|
+
// persistence, TTL, or size cap: freshness comes from entity subscriptions and
|
|
5
|
+
// periodic refetch, not from cache expiry, and growth is bounded by the ranges
|
|
6
|
+
// a single card visits in a session.
|
|
3
7
|
|
|
4
|
-
interface CacheEntry<T> {
|
|
8
|
+
export interface CacheEntry<T> {
|
|
5
9
|
data: T;
|
|
6
|
-
timestamp: number;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
|
-
export
|
|
10
|
-
try {
|
|
11
|
-
const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`);
|
|
12
|
-
if (!raw) return undefined;
|
|
12
|
+
export type Cache = Map<string, CacheEntry<unknown>>;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
localStorage.removeItem(`${CACHE_PREFIX}${key}`);
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
return entry.data;
|
|
20
|
-
} catch {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
14
|
+
export function readCache<T>(cache: Cache, key: string): T | undefined {
|
|
15
|
+
return (cache.get(key) as CacheEntry<T> | undefined)?.data;
|
|
23
16
|
}
|
|
24
17
|
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
const entry: CacheEntry<T> = { data, timestamp: Date.now() };
|
|
28
|
-
localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(entry));
|
|
29
|
-
} catch (e) {
|
|
30
|
-
console.warn('[preact-homeassistant cache] Failed to save:', e);
|
|
31
|
-
}
|
|
18
|
+
export function writeCache<T>(cache: Cache, key: string, data: T): void {
|
|
19
|
+
cache.set(key, { data });
|
|
32
20
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,10 +3,12 @@ export {
|
|
|
3
3
|
HAProvider,
|
|
4
4
|
useEntity,
|
|
5
5
|
useHass,
|
|
6
|
+
useHassValue,
|
|
7
|
+
useHassConfig,
|
|
8
|
+
useDarkMode,
|
|
6
9
|
useService,
|
|
7
10
|
useCachedFetch,
|
|
8
11
|
useCalendarEvents,
|
|
9
|
-
useMultiCalendarEvents,
|
|
10
12
|
useWeatherForecast,
|
|
11
13
|
} from './HAContext';
|
|
12
14
|
export { useCallbackStable } from './useCallbackStable';
|
|
@@ -17,7 +19,6 @@ export {
|
|
|
17
19
|
} from './useResizeObserver';
|
|
18
20
|
export { useWidth } from './useWidth';
|
|
19
21
|
export { css, registerRawStyles, getAllStyles } from './styleRegistry';
|
|
20
|
-
export { loadFromCache, saveToCache } from './cacheUtils';
|
|
21
22
|
|
|
22
23
|
export type {
|
|
23
24
|
HomeAssistant,
|
|
@@ -23,6 +23,13 @@ declare global {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Grace period before a disconnected card is torn down. HA detaches and
|
|
27
|
+
// re-attaches cards transiently (scroll virtualization, edit mode); only a card
|
|
28
|
+
// that stays gone past this window is genuinely removed, so we defer the
|
|
29
|
+
// Preact unmount — and the effect cleanups it triggers (timers, subscriptions)
|
|
30
|
+
// — until then. A reconnect within the window cancels the teardown.
|
|
31
|
+
const TEARDOWN_GRACE_MS = 5000;
|
|
32
|
+
|
|
26
33
|
export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>) {
|
|
27
34
|
const {
|
|
28
35
|
type,
|
|
@@ -34,21 +41,71 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
|
|
|
34
41
|
getStubConfig,
|
|
35
42
|
} = options;
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
// Shared host-element plumbing: config/hass storage, connect/disconnect
|
|
45
|
+
// lifecycle, deferred teardown, and the render skeleton. Subclasses supply
|
|
46
|
+
// the render root and the tree to render, and may override hass behavior.
|
|
47
|
+
abstract class BaseHACard extends HTMLElement {
|
|
48
|
+
protected _hass?: HomeAssistant;
|
|
49
|
+
protected _config?: TConfig;
|
|
50
|
+
private _teardownTimer?: ReturnType<typeof setTimeout>;
|
|
51
|
+
|
|
52
|
+
protected abstract _getRenderRoot(): Element | ShadowRoot;
|
|
53
|
+
protected abstract _renderTree(): void;
|
|
54
|
+
protected _renderUnconfigured(): void {}
|
|
55
|
+
|
|
56
|
+
connectedCallback() {
|
|
57
|
+
if (this._teardownTimer !== undefined) {
|
|
58
|
+
clearTimeout(this._teardownTimer);
|
|
59
|
+
this._teardownTimer = undefined;
|
|
60
|
+
}
|
|
61
|
+
this._maybeRenderOnConnect();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected _maybeRenderOnConnect() {
|
|
65
|
+
if (this._hass && this._config) {
|
|
66
|
+
this._render();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
disconnectedCallback() {
|
|
71
|
+
if (this._teardownTimer !== undefined) clearTimeout(this._teardownTimer);
|
|
72
|
+
this._teardownTimer = setTimeout(() => {
|
|
73
|
+
// Unmount the Preact tree, running effect cleanups (clears timers and
|
|
74
|
+
// entity subscriptions). Scheduled, never synchronous, so a transient
|
|
75
|
+
// disconnect+reconnect leaves the tree intact.
|
|
76
|
+
render(null, this._getRenderRoot());
|
|
77
|
+
this._teardownTimer = undefined;
|
|
78
|
+
}, TEARDOWN_GRACE_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setConfig(config: TConfig) {
|
|
82
|
+
this._config = config;
|
|
83
|
+
if (this._hass && this.isConnected) {
|
|
84
|
+
this._render();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
protected _render() {
|
|
89
|
+
if (!this._config || !this._hass) {
|
|
90
|
+
this._renderUnconfigured();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this._renderTree();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class HACard extends BaseHACard {
|
|
40
98
|
private _shadowRoot: ShadowRoot;
|
|
41
99
|
private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();
|
|
100
|
+
private _hassChangeListeners = new Set<() => void>();
|
|
42
101
|
|
|
43
102
|
constructor() {
|
|
44
103
|
super();
|
|
45
104
|
this._shadowRoot = this.attachShadow({ mode: 'open' });
|
|
46
105
|
}
|
|
47
106
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
this._render();
|
|
51
|
-
}
|
|
107
|
+
protected _getRenderRoot() {
|
|
108
|
+
return this._shadowRoot;
|
|
52
109
|
}
|
|
53
110
|
|
|
54
111
|
set hass(hass: HomeAssistant) {
|
|
@@ -63,14 +120,12 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
|
|
|
63
120
|
}
|
|
64
121
|
}
|
|
65
122
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
123
|
+
// Notify non-entity (config/themes) subscribers on every update. This does
|
|
124
|
+
// not re-render the whole card — useHassValue re-renders only its own
|
|
125
|
+
// consumer, and only when its selected slice actually changes.
|
|
126
|
+
this._hassChangeListeners.forEach((listener) => listener());
|
|
70
127
|
|
|
71
|
-
|
|
72
|
-
this._config = config;
|
|
73
|
-
if (this._hass && this.isConnected) {
|
|
128
|
+
if (!prevStates && this._config && this.isConnected) {
|
|
74
129
|
this._render();
|
|
75
130
|
}
|
|
76
131
|
}
|
|
@@ -92,23 +147,33 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
|
|
|
92
147
|
};
|
|
93
148
|
};
|
|
94
149
|
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
150
|
+
private _subscribeToHass = (callback: () => void) => {
|
|
151
|
+
this._hassChangeListeners.add(callback);
|
|
152
|
+
return () => {
|
|
153
|
+
this._hassChangeListeners.delete(callback);
|
|
154
|
+
};
|
|
155
|
+
};
|
|
102
156
|
|
|
157
|
+
protected _renderTree() {
|
|
103
158
|
render(
|
|
104
|
-
<HAProvider
|
|
159
|
+
<HAProvider
|
|
160
|
+
hass={this._hass}
|
|
161
|
+
subscribeToEntity={this._subscribeToEntity}
|
|
162
|
+
subscribeToHass={this._subscribeToHass}
|
|
163
|
+
>
|
|
105
164
|
<style>{getAllStyles()}</style>
|
|
106
|
-
<Component config={this._config} />
|
|
165
|
+
<Component config={this._config!} />
|
|
107
166
|
</HAProvider>,
|
|
108
167
|
this._shadowRoot,
|
|
109
168
|
);
|
|
110
169
|
}
|
|
111
170
|
|
|
171
|
+
protected _renderUnconfigured() {
|
|
172
|
+
if (UnconfiguredComponent) {
|
|
173
|
+
render(<UnconfiguredComponent />, this._shadowRoot);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
112
177
|
static getConfigElement() {
|
|
113
178
|
if (ConfigComponent) {
|
|
114
179
|
return document.createElement(`${type}-editor`);
|
|
@@ -126,15 +191,21 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
|
|
|
126
191
|
if (ConfigComponent) {
|
|
127
192
|
const EditorComponent = ConfigComponent;
|
|
128
193
|
|
|
129
|
-
class HACardEditor extends
|
|
130
|
-
|
|
131
|
-
|
|
194
|
+
class HACardEditor extends BaseHACard {
|
|
195
|
+
protected _getRenderRoot() {
|
|
196
|
+
return this;
|
|
197
|
+
}
|
|
132
198
|
|
|
199
|
+
// The editor re-renders on every hass update: it passes `hass` straight to
|
|
200
|
+
// HA's <ha-form>/<ha-selector>, whose entity pickers need a fresh hass to
|
|
201
|
+
// stay current. (Unlike the card, which renders once then subscribes.)
|
|
133
202
|
set hass(hass: HomeAssistant) {
|
|
134
203
|
this._hass = hass;
|
|
135
204
|
this._render();
|
|
136
205
|
}
|
|
137
206
|
|
|
207
|
+
// Render whenever config arrives, regardless of connection — HA may set
|
|
208
|
+
// config/hass before connecting the editor element.
|
|
138
209
|
setConfig(config: TConfig) {
|
|
139
210
|
this._config = config;
|
|
140
211
|
this._render();
|
|
@@ -150,14 +221,12 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
|
|
|
150
221
|
);
|
|
151
222
|
};
|
|
152
223
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Render to light DOM so HA's custom elements (ha-select etc.) work.
|
|
224
|
+
// Render to light DOM so HA's custom elements (ha-form etc.) work.
|
|
225
|
+
protected _renderTree() {
|
|
157
226
|
render(
|
|
158
227
|
<EditorComponent
|
|
159
|
-
hass={this._hass}
|
|
160
|
-
config={this._config}
|
|
228
|
+
hass={this._hass!}
|
|
229
|
+
config={this._config!}
|
|
161
230
|
onConfigChanged={this._fireConfigChanged}
|
|
162
231
|
/>,
|
|
163
232
|
this,
|