refactor: switch to using Bun (#718)

This commit is contained in:
Dan Ditomaso
2025-07-16 22:30:10 -04:00
committed by GitHub
parent acfd7ebd2d
commit 704d06cfe7
401 changed files with 35294 additions and 39107 deletions
@@ -15,7 +15,7 @@ export function useBrowserFeatureDetection(): BrowserSupport {
[
"Secure Context",
globalThis.location.protocol === "https:" ||
globalThis.location.hostname === "localhost",
globalThis.location.hostname === "localhost",
],
];
@@ -4,9 +4,9 @@ interface UseCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard(
{ timeout = 2000 }: UseCopyToClipboardProps = {},
) {
export function useCopyToClipboard({
timeout = 2000,
}: UseCopyToClipboardProps = {}) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
@@ -1,7 +1,7 @@
import type { Protobuf } from "@meshtastic/core";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFavoriteNode } from "./useFavoriteNode.ts";
import { Protobuf } from "@meshtastic/core";
const mockNode = {
num: 1234,
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
interface FavoriteNodeOptions {
@@ -16,7 +16,9 @@ export function useFavoriteNode() {
const updateFavoriteCB = useCallback(
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
const node = getNode(nodeNum);
if (!node) return;
if (!node) {
return;
}
updateFavorite(nodeNum, isFavorite);
@@ -32,7 +34,7 @@ export function useFavoriteNode() {
}),
});
},
[updateFavorite, getNode],
[updateFavorite, getNode, t, toast],
);
return { updateFavorite: updateFavoriteCB };
@@ -1,7 +1,7 @@
import type { Protobuf } from "@meshtastic/core";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIgnoreNode } from "./useIgnoreNode.ts";
import { Protobuf } from "@meshtastic/core";
const mockNode = {
num: 1234,
+6 -4
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
interface IgnoreNodeOptions {
@@ -17,7 +17,9 @@ export function useIgnoreNode() {
const updateIgnoredCB = useCallback(
({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
const node = getNode(nodeNum);
if (!node) return;
if (!node) {
return;
}
updateIgnored(nodeNum, isIgnored);
@@ -33,7 +35,7 @@ export function useIgnoreNode() {
}),
});
},
[updateIgnored, getNode],
[updateIgnored, getNode, t, toast],
);
return { updateIgnored: updateIgnoredCB };
@@ -1,7 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import { useCallback, useEffect, useRef } from "react";
import { useToast } from "@core/hooks/useToast.ts";
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
interface UseBackupReminderOptions {
@@ -23,9 +23,13 @@ const REMINDER_DAYS_FOREVER = 3650;
const STORAGE_KEY = "key_backup_reminder";
function isReminderExpired(expires?: string): boolean {
if (!expires) return true;
if (!expires) {
return true;
}
const expiryDate = new Date(expires);
if (isNaN(expiryDate.getTime())) return true; // Invalid date passed
if (Number.isNaN(expiryDate.getTime())) {
return true; // Invalid date passed
}
const now = new Date();
return now.getTime() >= expiryDate.getTime();
@@ -41,23 +45,26 @@ export function useBackupReminder({
const { toast } = useToast();
const toastShownRef = useRef(false);
const [reminderState, setReminderState] = useLocalStorage<
ReminderState | null
>(
STORAGE_KEY,
null,
const [reminderState, setReminderState] =
useLocalStorage<ReminderState | null>(STORAGE_KEY, null);
const setReminderExpiry = useCallback(
(days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setReminderState({ expires: expiryDate.toISOString() });
},
[setReminderState],
);
const setReminderExpiry = useCallback((days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setReminderState({ expires: expiryDate.toISOString() });
}, [setReminderState]);
useEffect(() => {
if (!enabled || toastShownRef.current) return;
if (!enabled || toastShownRef.current) {
return;
}
if (!isReminderExpired(reminderState?.expires)) return;
if (!isReminderExpired(reminderState?.expires)) {
return;
}
toastShownRef.current = true;
@@ -116,5 +123,10 @@ export function useBackupReminder({
enabled,
message,
onAccept,
reminderInDays,
reminderState?.expires,
setReminderExpiry,
t,
toast,
]);
}
+5 -5
View File
@@ -1,11 +1,11 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
FALLBACK_LANGUAGE_CODE,
Lang,
LangCode,
type Lang,
type LangCode,
supportedLanguages,
} from "@app/i18n-config.ts";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import useLocalStorage from "./useLocalStorage.ts";
const STORAGE_KEY = "language";
@@ -47,7 +47,7 @@ function useLang() {
console.warn("Failed to change language:", e);
}
},
[i18n, setLanguageInStorage],
[i18n.language, i18n.changeLanguage, setLanguageInStorage],
);
const compare = useCallback(
@@ -1,6 +1,6 @@
import { act, renderHook } from "@testing-library/react";
import useLocalStorage from "./useLocalStorage.ts";
import { beforeEach, describe, expect, it } from "vitest";
import useLocalStorage from "./useLocalStorage.ts";
describe("useLocalStorage", () => {
const key = "test-key";
+79 -126
View File
@@ -1,13 +1,9 @@
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
import { useCallback, useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface WindowEventMap {
"local-storage": CustomEvent;
"local-storage": CustomEvent<{ key: string }>;
}
}
@@ -19,14 +15,6 @@ type UseLocalStorageOptions<T> = {
const IS_SERVER = typeof window === "undefined";
/**
* Hook for persisting state to localStorage.
*
* @param {string} key - The key to use for localStorage.
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage.
* @param {UseLocalStorageOptions<T>} options - Options for the hook.
* @returns A tuple of [storedValue, setValue, removeValue].
*/
export default function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
@@ -34,149 +22,114 @@ export default function useLocalStorage<T>(
): [T, Dispatch<SetStateAction<T>>, () => void] {
const { initializeWithValue = true } = options;
const serializer = useCallback<(value: T) => string>(
(value) => {
if (options.serializer) {
return options.serializer(value);
}
const serializerRef = useRef(options.serializer ?? JSON.stringify);
const deserializerRef = useRef(options.deserializer ?? JSON.parse);
return JSON.stringify(value);
},
[options],
const initialValueRef = useRef<T>(
typeof initialValue === "function"
? (initialValue as () => T)()
: initialValue,
);
const deserializer = useCallback<(value: string) => T>(
(value) => {
if (options.deserializer) {
return options.deserializer(value);
}
// Support 'undefined' as a value
if (value === "undefined") {
return undefined as unknown as T;
}
const getInitialValue = useCallback(() => initialValueRef.current, []);
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
console.error("Error parsing JSON:", error);
return defaultValue; // Return initialValue if parsing fails
}
return parsed as T;
},
[options, initialValue],
);
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
const initialValueToUse = initialValue instanceof Function
? initialValue()
: initialValue;
// Prevent build error "window is undefined" but keep working
if (IS_SERVER) {
return initialValueToUse;
const [storedValue, setStoredValue] = useState<T>(() => {
if (!initializeWithValue || IS_SERVER) {
return getInitialValue();
}
try {
const raw = globalThis.localStorage.getItem(key);
return raw ? deserializer(raw) : initialValueToUse;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValueToUse;
const item = window.localStorage.getItem(key);
return item ? deserializerRef.current(item) : getInitialValue();
} catch (err) {
console.warn(`Error reading localStorage key “${key}”:`, err);
return getInitialValue();
}
}, [initialValue, key, deserializer]);
const [storedValue, setStoredValue] = useState(() => {
if (initializeWithValue) {
return readValue();
}
return initialValue instanceof Function ? initialValue() : initialValue;
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried setting localStorage key “${key}” even though environment is not a client`,
`Tried setting localStorage key “${key}” in a non-client environment.`,
);
return;
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(readValue()) : value;
// Save to local storage
globalThis.localStorage.setItem(key, serializer(newValue));
// Save state
setStoredValue(newValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
setStoredValue((prev) => {
const newValue =
typeof value === "function"
? (value as (prev: T) => T)(prev)
: value;
window.localStorage.setItem(key, serializerRef.current(newValue));
window.dispatchEvent(
new CustomEvent("local-storage", { detail: { key } }),
);
return newValue;
});
} catch (err) {
console.warn(`Error setting localStorage key “${key}”:`, err);
}
},
[key, serializer, readValue],
[key],
);
const removeValue = useCallback(() => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried removing localStorage key “${key}” even though environment is not a client`,
`Tried removing localStorage key “${key}” in a non-client environment.`,
);
return;
}
try {
window.localStorage.removeItem(key);
setStoredValue(getInitialValue());
window.dispatchEvent(
new CustomEvent("local-storage", { detail: { key } }),
);
} catch (err) {
console.warn(`Error removing localStorage key “${key}”:`, err);
}
}, [key, getInitialValue]);
useEffect(() => {
if (IS_SERVER) {
return;
}
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
const handleStorageChange = (
event: StorageEvent | CustomEvent<{ key: string }>,
) => {
const eventKey = "key" in event ? event.key : event.detail?.key;
// Remove the key from local storage
globalThis.localStorage.removeItem(key);
// Save state with default value
setStoredValue(defaultValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]);
useEffect(() => {
setStoredValue(readValue());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const handleStorageChange = useCallback(
(event: StorageEvent | CustomEvent) => {
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
return;
if (eventKey === key) {
try {
const item = window.localStorage.getItem(key);
setStoredValue(
item ? deserializerRef.current(item) : getInitialValue(),
);
} catch (err) {
console.warn(`Error syncing localStorage key “${key}”:`, err);
setStoredValue(getInitialValue());
}
}
setStoredValue(readValue());
},
[key, readValue],
);
useEffect(() => {
addEventListener("storage", handleStorageChange);
// this is a custom event, triggered in writeValueToLocalStorage
addEventListener("local-storage", handleStorageChange);
return () => {
removeEventListener("storage", handleStorageChange);
removeEventListener("local-storage", handleStorageChange);
};
}, []);
window.addEventListener("storage", handleStorageChange);
window.addEventListener(
"local-storage",
handleStorageChange as EventListener,
);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(
"local-storage",
handleStorageChange as EventListener,
);
};
}, [key, getInitialValue]);
return [storedValue, setValue, removeValue];
}
@@ -11,7 +11,7 @@ describe("usePasswordVisibilityToggle Hook", () => {
it("should initialize with visibility set to true if initialVisible is true", () => {
const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true })
usePasswordVisibilityToggle({ initialVisible: true }),
);
expect(result.current.isVisible).toBe(true);
});
@@ -27,7 +27,7 @@ describe("usePasswordVisibilityToggle Hook", () => {
it("should toggle visibility from true to false when toggleVisibility is called", () => {
const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true })
usePasswordVisibilityToggle({ initialVisible: true }),
);
expect(result.current.isVisible).toBe(true);
act(() => {
@@ -55,7 +55,7 @@ describe("usePasswordVisibilityToggle Hook", () => {
it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
const { result, rerender } = renderHook(() =>
usePasswordVisibilityToggle()
usePasswordVisibilityToggle(),
);
const initialToggleFunc = result.current.toggleVisibility;
rerender();
@@ -9,9 +9,9 @@ interface UsePasswordVisibilityToggleProps {
* @param {boolean} [options.initialVisible=false]
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
*/
export function usePasswordVisibilityToggle(
{ initialVisible = false }: UsePasswordVisibilityToggleProps = {},
) {
export function usePasswordVisibilityToggle({
initialVisible = false,
}: UsePasswordVisibilityToggleProps = {}) {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
const toggleVisibility = useCallback(() => {
+10 -5
View File
@@ -7,11 +7,16 @@ export function usePinnedItems({ storageName }: { storageName: string }) {
[],
);
const togglePinnedItem = useCallback((label: string) => {
setPinnedItems((prev) =>
prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label]
);
}, []);
const togglePinnedItem = useCallback(
(label: string) => {
setPinnedItems((prev) =>
prev.includes(label)
? prev.filter((g) => g !== label)
: [...prev, label],
);
},
[setPinnedItems],
);
return {
pinnedItems,
@@ -54,12 +54,14 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
const FLAGS_BITMASKS = useMemo(() => {
return Object.fromEntries(
Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]),
) as { [K in FlagName]: typeof FLAGS_CONFIG[K]["value"] };
) as { [K in FlagName]: (typeof FLAGS_CONFIG)[K]["value"] };
}, []);
const utils = useMemo(() => {
const decode = (value: number): FlagName[] => {
if (value === FLAGS_CONFIG.UNSET.value) return ["UNSET"];
if (value === FLAGS_CONFIG.UNSET.value) {
return ["UNSET"];
}
const activeFlags: FlagName[] = [];
for (const key in FLAGS_CONFIG) {
@@ -80,14 +82,17 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
return FLAGS_CONFIG.UNSET.value;
}
return flagNames.reduce((acc, name) => {
if (name === "UNSET") return acc;
if (name === "UNSET") {
return acc;
}
return acc | FLAGS_CONFIG[name].value;
}, 0);
};
const hasFlag = (value: number, flagName: FlagName): boolean => {
return (value & FLAGS_CONFIG[flagName].value) ===
FLAGS_CONFIG[flagName].value;
return (
(value & FLAGS_CONFIG[flagName].value) === FLAGS_CONFIG[flagName].value
);
};
const getAllFlags = (): typeof FLAGS_CONFIG => {
@@ -117,7 +122,7 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
const currentFlagValue = FLAGS_CONFIG[flagName].value;
setFlagsValue((prev) =>
enabled ? prev | currentFlagValue : prev & ~currentFlagValue
enabled ? prev | currentFlagValue : prev & ~currentFlagValue,
);
}, []);
+4 -2
View File
@@ -14,7 +14,7 @@ export function useTheme() {
);
const [preference, setPreference] = useState<Theme>(() =>
typeof window !== "undefined" ? getStoredPreference() : "light"
typeof window !== "undefined" ? getStoredPreference() : "light",
);
const theme = preference === "system" ? getSystemTheme() : preference;
@@ -24,7 +24,9 @@ export function useTheme() {
}, [theme]);
useEffect(() => {
if (preference !== "system") return;
if (preference !== "system") {
return;
}
const media = globalThis.matchMedia("(prefers-color-scheme: dark)");
const updateTheme = () => setPreference(getStoredPreference());
@@ -1,6 +1,6 @@
import { act, renderHook } from "@testing-library/react";
import { useToast } from "@core/hooks/useToast.ts";
import { Button } from "@components/UI/Button.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("useToast", () => {
+21 -20
View File
@@ -1,6 +1,5 @@
import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.tsx";
import { type ReactNode, useSyncExternalStore } from "react";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
@@ -31,21 +30,21 @@ type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
@@ -81,7 +80,7 @@ export const reducer = (state: State, action: Action): State => {
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
@@ -103,10 +102,10 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
...t,
open: false,
}
: t,
),
};
}
@@ -156,7 +155,9 @@ function toast({ delay = 0, ...props }: Toast) {
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
if (!open) {
dismiss();
}
},
},
});
@@ -0,0 +1,24 @@
import {
useDevice,
type ValidConfigType,
type ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
type UseWaitForConfigProps =
| { configCase: ValidConfigType; moduleConfigCase?: never }
| { configCase?: never; moduleConfigCase: ValidModuleConfigType };
export function useWaitForConfig({
configCase,
moduleConfigCase,
}: UseWaitForConfigProps): void {
const { config, moduleConfig } = useDevice();
const isDataDefined = configCase
? config[configCase] !== undefined
: moduleConfig[moduleConfigCase as ValidModuleConfigType] !== undefined;
if (!isDataDefined) {
throw new Promise<void>(() => {});
}
}