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.
@@ -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 { createMockSubscribe, makeHass, renderWithHA } from './testHelpers';
5
+ import { renderWithHA } from './testHelpers';
6
6
 
7
- let lastStatus: FetchStatus;
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
- lastStatus = status;
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
- localStorage.clear();
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
- // Pre-populate cache
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
- // Should show cached data
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('saves fetched data to cache', async () => {
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
- subscribeFn: subscribe,
93
+ cache,
104
94
  });
105
95
 
106
96
  await waitFor(() => {
107
97
  expect(screen.getByTestId('status').textContent).toBe('ready');
108
98
  });
109
99
 
110
- const cached = localStorage.getItem('preact-ha:cache-write-test');
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 { useMultiCalendarEvents } from '../HAContext';
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 } = useMultiCalendarEvents(entityIds, {
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('useMultiCalendarEvents', () => {
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 { beforeEach, describe, expect, it, vi } from 'vitest';
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 localStorage on update', async () => {
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
- const cached = localStorage.getItem('preact-ha:entity:sensor.temp');
80
- expect(cached).toBeTruthy();
81
- const parsed = JSON.parse(cached!);
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
- // Pre-populate cache
87
- const entry = {
88
- data: { entity_id: 'sensor.temp', state: '68' },
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
- const CACHE_PREFIX = 'preact-ha:';
2
- const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000;
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 function loadFromCache<T>(key: string): T | undefined {
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
- const entry: CacheEntry<T> = JSON.parse(raw);
15
- if (Date.now() - entry.timestamp > CACHE_EXPIRY_MS) {
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 saveToCache<T>(key: string, data: T): void {
26
- try {
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
- class HACard extends HTMLElement {
38
- private _hass?: HomeAssistant;
39
- private _config?: TConfig;
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
- connectedCallback() {
49
- if (this._hass && this._config) {
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
- if (!prevStates && this._config && this.isConnected) {
67
- this._render();
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
- setConfig(config: TConfig) {
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 _render() {
96
- if (!this._config || !this._hass) {
97
- if (UnconfiguredComponent) {
98
- render(<UnconfiguredComponent />, this._shadowRoot);
99
- }
100
- return;
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 hass={this._hass} subscribeToEntity={this._subscribeToEntity}>
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 HTMLElement {
130
- private _hass?: HomeAssistant;
131
- private _config?: TConfig;
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
- private _render() {
154
- if (!this._hass || !this._config) return;
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,