Skip to content

Commit 4b8e354

Browse files
Refactor connection/authenticatioin with retry functionality (#264)
* Feature/throttle updates and responsive bug (#241) * Fixing throttle updates and resposnive issue * Updating package and changelog with locales * Small refactor * Updating changelog * 248 reported delays with card updates (#249) * Updates with timecard issues * Fixed bug where ticking function wouldn't update the clock if no entity or other props are provided * Optimsations with tooltip * updating log * Updating changelog * Reverting time card story changes * Typo * Locales, prettier & bumping version * Trying to simplify / improve on authentication / socket connections * Will cleanup once we have a working bugfix * Adjusting fetch so it makes the request when locale is absent * Removing package prefix
1 parent 7ab5b33 commit 4b8e354

File tree

6 files changed

+129
-95
lines changed

6 files changed

+129
-95
lines changed

hass-connect-fake/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ const ignoreForDiffCheck = (
224224
};
225225

226226
const useStore = create<Store>((set) => ({
227+
disconnectCallbacks: [],
228+
onDisconnect: (cb) => set((state) => ({ disconnectCallbacks: [...state.disconnectCallbacks, cb] })),
229+
triggerOnDisconnect: () => {
230+
set((state) => {
231+
state.disconnectCallbacks.forEach((callback) => callback());
232+
return { disconnectCallbacks: [] };
233+
});
234+
},
227235
routes: [],
228236
setRoutes: (routes) => set(() => ({ routes })),
229237
hash: '',

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"build": "VITE_CJS_TRACE=true npm run build:core && npm run build:components && npm run build:create",
3737
"postbuild": "npm run type-check",
3838
"type-check": "tsc --noEmit --project tsconfig-type-check.json && npm run type-check --workspaces --if-present",
39-
"build:core": "npm run build --workspace=@hakit/core",
39+
"build:core": "npm run build:core --workspace=@hakit/core",
4040
"build:create": "npm run build --workspace=create-hakit",
4141
"build:for:storybook": "npm run build:core --workspace=@hakit/core && npm run build --workspace=@hakit/components",
4242
"build:components": "npm run build --workspace=@hakit/components",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useConfig } from "@hooks";
2+
import { Locales } from "@typings";
3+
import { useEffect, useRef, useState } from "react";
4+
import { updateLocales } from "../../hooks/useLocale";
5+
import locales from "../../hooks/useLocale/locales";
6+
import { useStore } from "../HassContext";
7+
8+
interface FetchLocaleProps {
9+
locale?: Locales;
10+
children?: React.ReactNode;
11+
}
12+
export function FetchLocale({ locale, children }: FetchLocaleProps) {
13+
const config = useConfig();
14+
const [fetched, setFetched] = useState(false);
15+
const fetchPending = useRef(false);
16+
const previousLocale = useRef<Locales | null>(null);
17+
const setError = useStore((store) => store.setError);
18+
const setLocales = useStore((store) => store.setLocales);
19+
20+
useEffect(() => {
21+
const _locale = (locale ?? config?.language);
22+
if (!_locale) {
23+
// may just be waiting for the users config to resolve
24+
return;
25+
}
26+
const match = locales.find(({ code }) => code === (locale ?? config?.language));
27+
if (previousLocale.current !== match?.code) {
28+
setFetched(false);
29+
fetchPending.current = false;
30+
setError(null);
31+
}
32+
33+
if (!match) {
34+
fetchPending.current = false;
35+
setError(
36+
`Locale "${locale ?? config?.language}" not found, available options are "${locales.map(({ code }) => `${code}`).join(", ")}"`,
37+
);
38+
} else {
39+
if (fetchPending.current) return
40+
fetchPending.current = true;
41+
previousLocale.current = match.code;
42+
match
43+
.fetch()
44+
.then((response) => {
45+
fetchPending.current = false;
46+
setFetched(true);
47+
updateLocales(response);
48+
setLocales(response);
49+
})
50+
.catch((e) => {
51+
fetchPending.current = false;
52+
setFetched(true);
53+
setError(`Error retrieving translations from Home Assistant: ${e?.message ?? e}`);
54+
});
55+
}
56+
}, [config, fetched, setLocales, setError, locale]);
57+
58+
return fetched ? children : null;
59+
}

packages/core/src/HassConnect/HassContext.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ export interface Store {
9292
// used by some features to change which window context to use
9393
setWindowContext: (windowContext: Window) => void;
9494
windowContext: Window;
95+
/** internal - callbacks that will fire when the connection disconnects with home assistant */
96+
disconnectCallbacks: (() => void)[];
97+
/** use this to trigger certain functionality when the web socket connection disconnects */
98+
onDisconnect?: (cb: () => void) => void;
99+
/** internal function which will trigger when the connection disconnects */
100+
triggerOnDisconnect: () => void;
95101
}
96102

97103
const IGNORE_KEYS_FOR_DIFF = ["last_changed", "last_updated", "context"];
@@ -192,6 +198,13 @@ export const useStore = create<Store>((set) => ({
192198
}),
193199
globalComponentStyles: {},
194200
setGlobalComponentStyles: (styles) => set(() => ({ globalComponentStyles: styles })),
201+
disconnectCallbacks: [],
202+
onDisconnect: (cb) => set((state) => ({ disconnectCallbacks: [...state.disconnectCallbacks, cb] })),
203+
triggerOnDisconnect: () =>
204+
set((state) => {
205+
state.disconnectCallbacks.forEach((cb) => cb());
206+
return { disconnectCallbacks: [] };
207+
}),
195208
}));
196209

197210
export interface HassContextProps {

packages/core/src/HassConnect/Provider.tsx

Lines changed: 37 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useCallback, useRef } from "react";
22
// types
3-
import type { Connection, HassConfig, getAuthOptions as AuthOptions, Auth, UnsubscribeFunc } from "home-assistant-js-websocket";
3+
import type { Connection, getAuthOptions as AuthOptions, Auth, UnsubscribeFunc } from "home-assistant-js-websocket";
44
// methods
55
import {
66
getAuth,
@@ -23,8 +23,6 @@ import { isArray, snakeCase } from "lodash";
2323
import { SnakeOrCamelDomains, DomainService, Locales, CallServiceArgs, Route, ServiceResponse } from "@typings";
2424
import { saveTokens, loadTokens, clearTokens } from "./token-storage";
2525
import { useDebouncedCallback } from "use-debounce";
26-
import locales from "../hooks/useLocale/locales";
27-
import { updateLocales } from "../hooks/useLocale";
2826
import { HassContext, type HassContextProps, useStore } from "./HassContext";
2927

3028
export interface HassProviderProps {
@@ -179,7 +177,7 @@ const tryConnection = async (hassUrl: string, hassToken?: string): Promise<Conne
179177
try {
180178
new URL(options.hassUrl);
181179
} catch (err: unknown) {
182-
console.log("Error:", err);
180+
console.error("Error:", err);
183181
return {
184182
type: "error",
185183
error: "Invalid URL",
@@ -246,10 +244,10 @@ const tryConnection = async (hassUrl: string, hassToken?: string): Promise<Conne
246244
};
247245
};
248246

249-
export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot, windowContext }: HassProviderProps) {
247+
export function HassProvider({ children, hassUrl, hassToken, portalRoot, windowContext }: HassProviderProps) {
250248
const entityUnsubscribe = useRef<UnsubscribeFunc | null>(null);
251249
const authenticated = useRef(false);
252-
const subscribedConfig = useRef(false);
250+
const configUnsubscribe = useRef<UnsubscribeFunc | null>(null);
253251
const setHash = useStore((store) => store.setHash);
254252
const _hash = useStore((store) => store.hash);
255253
const routes = useStore((store) => store.routes);
@@ -264,12 +262,13 @@ export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot,
264262
const cannotConnect = useStore((store) => store.cannotConnect);
265263
const setCannotConnect = useStore((store) => store.setCannotConnect);
266264
const setAuth = useStore((store) => store.setAuth);
265+
const triggerOnDisconnect = useStore((store) => store.triggerOnDisconnect);
266+
// ready is set internally in the store when we have entities (setEntities does this)
267267
const ready = useStore((store) => store.ready);
268268
const setReady = useStore((store) => store.setReady);
269269
const setConfig = useStore((store) => store.setConfig);
270270
const setHassUrl = useStore((store) => store.setHassUrl);
271271
const setPortalRoot = useStore((store) => store.setPortalRoot);
272-
const setLocales = useStore((store) => store.setLocales);
273272
const setWindowContext = useStore((store) => store.setWindowContext);
274273

275274
useEffect(() => {
@@ -292,6 +291,10 @@ export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot,
292291
setReady(false);
293292
setRoutes([]);
294293
authenticated.current = false;
294+
if (configUnsubscribe.current) {
295+
configUnsubscribe.current();
296+
configUnsubscribe.current = null;
297+
}
295298
if (entityUnsubscribe.current) {
296299
entityUnsubscribe.current();
297300
entityUnsubscribe.current = null;
@@ -304,7 +307,7 @@ export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot,
304307
clearTokens();
305308
if (location) location.reload();
306309
} catch (err: unknown) {
307-
console.log("Error:", err);
310+
console.error("Error:", err);
308311
setError("Unable to log out!");
309312
}
310313
}, [reset, setError]);
@@ -323,10 +326,28 @@ export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot,
323326
setAuth(connectionResponse.auth);
324327
// store the connection to pass to the provider
325328
setConnection(connectionResponse.connection);
329+
entityUnsubscribe.current = subscribeEntities(connectionResponse.connection, ($entities) => {
330+
setEntities($entities);
331+
});
332+
configUnsubscribe.current = subscribeConfig(connectionResponse.connection, (newConfig) => {
333+
setConfig(newConfig);
334+
});
335+
connectionResponse.connection.addEventListener("disconnected", () => {
336+
console.error("Disconnected from Home Assistant, reconnecting...");
337+
triggerOnDisconnect();
338+
// on disconnection, reset local state
339+
reset();
340+
// try to reconnect
341+
handleConnect();
342+
});
343+
connectionResponse.connection.addEventListener("reconnect-error", (_, eventData) => {
344+
console.error("Reconnection error:", eventData);
345+
// on connection error, reset local state
346+
reset();
347+
});
326348
_connectionRef.current = connectionResponse.connection;
327-
authenticated.current = true;
328349
}
329-
}, [hassUrl, hassToken, setError, setAuth, setConnection, setCannotConnect]);
350+
}, [hassUrl, hassToken, triggerOnDisconnect, setError, setCannotConnect, setAuth, setConnection, setEntities, setConfig, reset]);
330351

331352
useEffect(() => {
332353
setHassUrl(hassUrl);
@@ -379,64 +400,14 @@ export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot,
379400
data: response.statusText,
380401
};
381402
} catch (e) {
382-
console.log("Error:", e);
403+
console.error("API Error:", e);
383404
return {
384405
status: "error",
385406
data: `API Request failed for endpoint "${endpoint}", follow instructions here: https://shannonhochkins.github.io/ha-component-kit/?path=/docs/core-hooks-usehass-hass-callapi--docs.`,
386407
};
387408
}
388409
}
389410

390-
const fetchLocale = useCallback(
391-
async (config: HassConfig | null): Promise<Record<string, string>> => {
392-
const match = locales.find(({ code }) => code === (locale ?? config?.language));
393-
if (!match) {
394-
throw new Error(
395-
`Locale "${locale ?? config?.language}" not found, available options are "${locales.map(({ code }) => `${code}`).join(", ")}"`,
396-
);
397-
} else {
398-
return await match.fetch();
399-
}
400-
},
401-
[locale],
402-
);
403-
404-
useEffect(() => {
405-
if (!locale) return;
406-
// purposely sending null for the config object as we're fetching a different language specified by the user
407-
fetchLocale(null)
408-
.then((locales) => {
409-
updateLocales(locales);
410-
setLocales(locales);
411-
})
412-
.catch((e) => {
413-
setError(`Error retrieving translations from Home Assistant: ${e?.message ?? e}`);
414-
});
415-
}, [locale, fetchLocale, setLocales, setError]);
416-
417-
useEffect(() => {
418-
if (!connection || subscribedConfig.current) return;
419-
subscribedConfig.current = true;
420-
// Subscribe to config updates
421-
const unsubscribe = subscribeConfig(connection, (newConfig) => {
422-
fetchLocale(newConfig)
423-
.then((locales) => {
424-
// purposely setting config here to delay the rendering process of the application until locales are retrieved
425-
setConfig(newConfig);
426-
updateLocales(locales);
427-
setLocales(locales);
428-
})
429-
.catch((e) => {
430-
setConfig(newConfig);
431-
setError(`Error retrieving translations from Home Assistant: ${e?.message ?? e}`);
432-
});
433-
});
434-
// Cleanup function to unsubscribe on unmount
435-
return () => {
436-
unsubscribe();
437-
};
438-
}, [connection, setLocales, fetchLocale, setConfig, setError]);
439-
440411
useEffect(() => {
441412
if (location.hash === "") return;
442413
if (location.hash.replace("#", "") === _hash) return;
@@ -540,46 +511,26 @@ export function HassProvider({ children, hassUrl, hassToken, locale, portalRoot,
540511
[connection, ready],
541512
);
542513

543-
useEffect(() => {
544-
if (connection && entityUnsubscribe.current === null) {
545-
entityUnsubscribe.current = subscribeEntities(connection, ($entities) => {
546-
setEntities($entities);
547-
});
548-
}
549-
}, [connection, setEntities]);
550-
551514
useEffect(() => {
552515
return () => {
553-
authenticated.current = false;
554-
if (entityUnsubscribe.current) {
555-
entityUnsubscribe.current();
556-
entityUnsubscribe.current = null;
557-
}
516+
reset();
558517
};
559-
}, []);
518+
}, [reset]);
560519

561520
const debounceConnect = useDebouncedCallback(
562521
async () => {
563522
try {
564-
if (_connectionRef.current && !connection) {
565-
setConnection(_connectionRef.current);
566-
authenticated.current = true;
567-
return;
568-
}
569-
if (!_connectionRef.current && connection) {
570-
_connectionRef.current = connection;
571-
authenticated.current = true;
572-
return;
523+
if (authenticated.current) {
524+
reset();
573525
}
574-
if (authenticated.current) return;
575526
authenticated.current = true;
576527
await handleConnect();
577528
} catch (e) {
578529
const message = handleError(e);
579530
setError(`Unable to connect to Home Assistant, please check the URL: "${message}"`);
580531
}
581532
},
582-
100,
533+
25,
583534
{
584535
leading: true,
585536
trailing: false,

packages/core/src/HassConnect/index.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HassProvider } from "./Provider";
44
import type { HassProviderProps } from "./Provider";
55
import styled from "@emotion/styled";
66
import { keyframes } from "@emotion/react";
7+
import { FetchLocale } from "./FetchLocale";
78

89
export type HassConnectProps = {
910
/** Any react node to render when authenticated */
@@ -107,14 +108,16 @@ export const HassConnect = memo(function HassConnect({
107108
<>
108109
{ready ? (
109110
<Wrapper>
110-
{onReady &&
111-
!onReadyCalled.current &&
112-
((() => {
113-
onReady();
114-
onReadyCalled.current = true;
115-
})(),
116-
null)}
117-
{children}
111+
<FetchLocale locale={options.locale}>
112+
{onReady &&
113+
!onReadyCalled.current &&
114+
((() => {
115+
onReady();
116+
onReadyCalled.current = true;
117+
})(),
118+
null)}
119+
{children}
120+
</FetchLocale>
118121
</Wrapper>
119122
) : (
120123
<Wrapper>{loading}</Wrapper>

0 commit comments

Comments
 (0)