mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-16 12:49:56 +03:00
optimizations
This commit is contained in:
@@ -1,60 +1,67 @@
|
||||
export const numberValue = (value?: number) => {
|
||||
if (value !== undefined) {
|
||||
return isNaN(value) ? '' : value.toString();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
/**
|
||||
* Converts a number value to a string for input fields.
|
||||
* Returns empty string for undefined or NaN values.
|
||||
*/
|
||||
export const numberValue = (value?: number): string =>
|
||||
value === undefined || isNaN(value) ? '' : String(value);
|
||||
|
||||
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
switch (event.target.type) {
|
||||
case 'number':
|
||||
return event.target.valueAsNumber;
|
||||
case 'checkbox':
|
||||
return event.target.checked;
|
||||
default:
|
||||
return event.target.value;
|
||||
}
|
||||
/**
|
||||
* Extracts the appropriate value from an input event based on input type.
|
||||
*/
|
||||
export const extractEventValue = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): string | number | boolean => {
|
||||
const { type, valueAsNumber, checked, value } = event.target;
|
||||
|
||||
if (type === 'number') return valueAsNumber;
|
||||
if (type === 'checkbox') return checked;
|
||||
return value;
|
||||
};
|
||||
|
||||
type UpdateEntity<S> = (state: (prevState: Readonly<S>) => S) => void;
|
||||
|
||||
/**
|
||||
* Creates an event handler that updates an entity's state based on input changes.
|
||||
*/
|
||||
export const updateValue =
|
||||
<S>(updateEntity: UpdateEntity<S>) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
<S extends Record<string, unknown>>(updateEntity: UpdateEntity<S>) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { name } = event.target;
|
||||
const value = extractEventValue(event);
|
||||
|
||||
updateEntity((prevState) => ({
|
||||
...prevState,
|
||||
[event.target.name]: extractEventValue(event)
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an event handler that tracks dirty flags for modified fields.
|
||||
* Optimized to minimize state updates and unnecessary array operations.
|
||||
*/
|
||||
export const updateValueDirty =
|
||||
(
|
||||
origData: unknown,
|
||||
<T extends Record<string, unknown>>(
|
||||
origData: T,
|
||||
dirtyFlags: string[],
|
||||
setDirtyFlags: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
updateDataValue: (value: unknown) => void
|
||||
updateDataValue: (updater: (prevState: T) => T) => void
|
||||
) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updated_value = extractEventValue(event);
|
||||
const name = event.target.name;
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { name } = event.target;
|
||||
const updatedValue = extractEventValue(event);
|
||||
|
||||
updateDataValue((prevState: unknown) => ({
|
||||
...(prevState as Record<string, unknown>),
|
||||
[name]: updated_value
|
||||
updateDataValue((prevState) => ({
|
||||
...prevState,
|
||||
[name]: updatedValue
|
||||
}));
|
||||
|
||||
const arr: string[] = dirtyFlags;
|
||||
const isDirty = origData[name] !== updatedValue;
|
||||
const wasDirty = dirtyFlags.includes(name);
|
||||
|
||||
if ((origData as Record<string, unknown>)[name] !== updated_value) {
|
||||
if (!arr.includes(name)) {
|
||||
arr.push(name);
|
||||
}
|
||||
} else {
|
||||
const startIndex = arr.indexOf(name);
|
||||
if (startIndex !== -1) {
|
||||
arr.splice(startIndex, 1);
|
||||
}
|
||||
// Only update dirty flags if the state changed
|
||||
if (isDirty !== wasDirty) {
|
||||
setDirtyFlags(
|
||||
isDirty ? [...dirtyFlags, name] : dirtyFlags.filter((f) => f !== name)
|
||||
);
|
||||
}
|
||||
|
||||
setDirtyFlags(arr);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
export const saveFile = (json: unknown, filename: string, extension: string) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(json, null, 2)], {
|
||||
type: 'text/plain'
|
||||
})
|
||||
);
|
||||
anchor.download = 'emsesp_' + filename + extension;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(anchor.href);
|
||||
export const saveFile = (
|
||||
json: unknown,
|
||||
filename: string,
|
||||
extension: string
|
||||
): void => {
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(json, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `emsesp_${filename}${extension}`;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
// Delay revocation to ensure download starts properly
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
throw new Error(`Unable to save file: ${filename}${extension}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Cache for formatters to avoid recreation
|
||||
// Cache for formatters to avoid recreation (with size limits to prevent memory leaks)
|
||||
const MAX_CACHE_SIZE = 50;
|
||||
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const rtfCache = new Map<string, Intl.RelativeTimeFormat>();
|
||||
|
||||
// Pre-computed time divisions for relative time formatting
|
||||
// Pre-computed constants
|
||||
const MS_TO_MINUTES = 60000; // 60 * 1000
|
||||
const TIME_DIVISIONS = [
|
||||
{ amount: 60, name: 'seconds' as const },
|
||||
{ amount: 60, name: 'minutes' as const },
|
||||
@@ -13,30 +15,79 @@ const TIME_DIVISIONS = [
|
||||
{ amount: Number.POSITIVE_INFINITY, name: 'years' as const }
|
||||
] as const;
|
||||
|
||||
// Cached navigator languages to avoid repeated array spreads
|
||||
let cachedLanguages: readonly string[] | null = null;
|
||||
|
||||
/**
|
||||
* Get or create a cached DateTimeFormat instance
|
||||
* Get navigator languages with caching
|
||||
*/
|
||||
function getNavigatorLanguages(): readonly string[] {
|
||||
if (!cachedLanguages) {
|
||||
cachedLanguages = window.navigator.languages;
|
||||
}
|
||||
return cachedLanguages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fast cache key from DateTimeFormat options
|
||||
*/
|
||||
function createFormatterKey(options: Intl.DateTimeFormatOptions): string {
|
||||
// Build key from most common properties for better performance than JSON.stringify
|
||||
return `${options.day}-${options.month}-${options.year}-${options.hour}-${options.minute}-${options.second}-${options.hour12}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached DateTimeFormat instance with LRU-like cache management
|
||||
*/
|
||||
function getDateTimeFormatter(
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
const key = JSON.stringify(options);
|
||||
if (!formatterCache.has(key)) {
|
||||
formatterCache.set(
|
||||
key,
|
||||
new Intl.DateTimeFormat([...window.navigator.languages], options)
|
||||
);
|
||||
const key = createFormatterKey(options);
|
||||
|
||||
if (formatterCache.has(key)) {
|
||||
// Move to end for LRU behavior
|
||||
const formatter = formatterCache.get(key)!;
|
||||
formatterCache.delete(key);
|
||||
formatterCache.set(key, formatter);
|
||||
return formatter;
|
||||
}
|
||||
return formatterCache.get(key)!;
|
||||
|
||||
// Limit cache size
|
||||
if (formatterCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = formatterCache.keys().next().value;
|
||||
if (firstKey) {
|
||||
formatterCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(getNavigatorLanguages(), options);
|
||||
formatterCache.set(key, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached RelativeTimeFormat instance
|
||||
* Get or create a cached RelativeTimeFormat instance with cache size management
|
||||
*/
|
||||
function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
|
||||
if (!rtfCache.has(locale)) {
|
||||
rtfCache.set(locale, new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }));
|
||||
if (rtfCache.has(locale)) {
|
||||
// Move to end for LRU behavior
|
||||
const formatter = rtfCache.get(locale)!;
|
||||
rtfCache.delete(locale);
|
||||
rtfCache.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
return rtfCache.get(locale)!;
|
||||
|
||||
// Limit cache size
|
||||
if (rtfCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = rtfCache.keys().next().value;
|
||||
if (firstKey) {
|
||||
rtfCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
rtfCache.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +100,7 @@ function formatTimeAgo(locale: string, date: Date): string {
|
||||
|
||||
const rtf = getRelativeTimeFormatter(locale);
|
||||
|
||||
// Use for...of for better performance and readability
|
||||
// Find the appropriate time division
|
||||
for (const division of TIME_DIVISIONS) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return rtf.format(Math.round(duration), division.name);
|
||||
@@ -57,7 +108,8 @@ function formatTimeAgo(locale: string, date: Date): string {
|
||||
duration /= division.amount;
|
||||
}
|
||||
|
||||
return rtf.format(0, 'seconds');
|
||||
// This should never be reached due to POSITIVE_INFINITY in divisions
|
||||
return rtf.format(Math.round(duration), 'years');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,8 +154,8 @@ export const formatLocalDateTime = (date: Date): string => {
|
||||
return 'Invalid date';
|
||||
}
|
||||
|
||||
// Calculate local time offset in milliseconds
|
||||
const offsetMs = date.getTimezoneOffset() * 60000;
|
||||
// Calculate local time offset using pre-computed constant
|
||||
const offsetMs = date.getTimezoneOffset() * MS_TO_MINUTES;
|
||||
const localTime = date.getTime() - offsetMs;
|
||||
|
||||
// Convert to ISO string and remove timezone info
|
||||
|
||||
@@ -2,24 +2,43 @@ import { useEffect, useRef } from 'react';
|
||||
|
||||
const DEFAULT_DELAY = 3000;
|
||||
|
||||
// adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/
|
||||
export const useInterval = (callback: () => void, delay: number = DEFAULT_DELAY) => {
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const savedCallback = useRef<() => void>(callback);
|
||||
/**
|
||||
* Custom hook for setting up an interval with proper cleanup
|
||||
* Adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/
|
||||
*
|
||||
* @param callback - Function to be called at each interval
|
||||
* @param delay - Delay in milliseconds (default: 3000ms)
|
||||
* @param immediate - If true, executes callback immediately on mount (default: false)
|
||||
* @returns Reference to the interval ID
|
||||
*/
|
||||
export const useInterval = (
|
||||
callback: () => void,
|
||||
delay: number = DEFAULT_DELAY,
|
||||
immediate = false
|
||||
) => {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const savedCallback = useRef(callback);
|
||||
|
||||
// Remember the latest callback without resetting the interval
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => savedCallback.current();
|
||||
intervalRef.current = window.setInterval(tick, delay);
|
||||
|
||||
// Execute immediately if requested
|
||||
if (immediate) {
|
||||
tick();
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(tick, delay);
|
||||
return () => {
|
||||
if (intervalRef.current !== null) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [delay]);
|
||||
}, [delay, immediate]);
|
||||
|
||||
return intervalRef;
|
||||
};
|
||||
|
||||
@@ -4,23 +4,38 @@ export const usePersistState = <T>(
|
||||
initial_value: T,
|
||||
id: string
|
||||
): [T, (new_state: T) => void] => {
|
||||
// Set initial value
|
||||
// Set initial value - only computed once on mount
|
||||
const _initial_value = useMemo(() => {
|
||||
const local_storage_value_str = localStorage.getItem('state:' + id);
|
||||
// If there is a value stored in localStorage, use that
|
||||
if (local_storage_value_str) {
|
||||
return JSON.parse(local_storage_value_str) as T;
|
||||
try {
|
||||
const local_storage_value_str = localStorage.getItem(`state:${id}`);
|
||||
// If there is a value stored in localStorage, use that
|
||||
if (local_storage_value_str) {
|
||||
return JSON.parse(local_storage_value_str) as T;
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, fall back to initial_value
|
||||
console.warn(
|
||||
`Failed to parse localStorage value for key "state:${id}"`,
|
||||
error
|
||||
);
|
||||
}
|
||||
// Otherwise use initial_value that was passed to the function
|
||||
return initial_value;
|
||||
}, []);
|
||||
}, [id]); // initial_value intentionally omitted - only read on first mount
|
||||
|
||||
const [state, setState] = useState(_initial_value);
|
||||
|
||||
useEffect(() => {
|
||||
const state_str = JSON.stringify(state); // Stringified state
|
||||
localStorage.setItem('state:' + id, state_str); // Set stringified state as item in localStorage
|
||||
}, [state]);
|
||||
try {
|
||||
const state_str = JSON.stringify(state);
|
||||
localStorage.setItem(`state:${id}`, state_str);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to save state to localStorage for key "state:${id}"`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}, [state, id]);
|
||||
|
||||
return [state, setState];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -11,10 +11,12 @@ export interface RestRequestOptions<D> {
|
||||
update: (value: D) => Method<AlovaGenerics>;
|
||||
}
|
||||
|
||||
const REBOOT_ERROR_MESSAGE = 'Reboot required';
|
||||
|
||||
export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
|
||||
const [restartNeeded, setRestartNeeded] = useState(false);
|
||||
const [origData, setOrigData] = useState<D>();
|
||||
const [dirtyFlags, setDirtyFlags] = useState<string[]>([]);
|
||||
const blocker = useBlocker(dirtyFlags.length !== 0);
|
||||
@@ -35,55 +37,71 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
|
||||
setDirtyFlags([]);
|
||||
});
|
||||
|
||||
// Memoize updateDataValue to prevent unnecessary re-renders
|
||||
const updateDataValue = useCallback(
|
||||
(new_data: D) => {
|
||||
updateData({ data: new_data });
|
||||
},
|
||||
(new_data: D) => updateData({ data: new_data }),
|
||||
[updateData]
|
||||
);
|
||||
|
||||
// Memoize loadData to prevent unnecessary re-renders
|
||||
const loadData = useCallback(async () => {
|
||||
setDirtyFlags([]);
|
||||
setErrorMessage(undefined);
|
||||
await readData().catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
setErrorMessage(error.message);
|
||||
});
|
||||
try {
|
||||
await readData();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
setErrorMessage(message);
|
||||
}
|
||||
}, [readData]);
|
||||
|
||||
// Memoize saveData to prevent unnecessary re-renders
|
||||
const saveData = useCallback(async () => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (!data) return;
|
||||
|
||||
// Reset states before saving
|
||||
setRestartNeeded(false);
|
||||
setErrorMessage(undefined);
|
||||
setDirtyFlags([]);
|
||||
setOrigData(data as D);
|
||||
await writeData(data as D).catch((error: Error) => {
|
||||
if (error.message === 'Reboot required') {
|
||||
|
||||
try {
|
||||
await writeData(data as D);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message === REBOOT_ERROR_MESSAGE) {
|
||||
setRestartNeeded(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
setErrorMessage(error.message);
|
||||
toast.error(message);
|
||||
setErrorMessage(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [data, writeData]);
|
||||
|
||||
return {
|
||||
loadData,
|
||||
saveData,
|
||||
saving: saving as boolean,
|
||||
updateDataValue,
|
||||
data: data as D,
|
||||
origData: origData as D,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
setOrigData,
|
||||
blocker,
|
||||
errorMessage,
|
||||
restartNeeded
|
||||
} as const;
|
||||
return useMemo(
|
||||
() => ({
|
||||
loadData,
|
||||
saveData,
|
||||
saving: !!saving,
|
||||
updateDataValue,
|
||||
data: data as D,
|
||||
origData: origData as D,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
setOrigData,
|
||||
blocker,
|
||||
errorMessage,
|
||||
restartNeeded
|
||||
}),
|
||||
[
|
||||
loadData,
|
||||
saveData,
|
||||
saving,
|
||||
updateDataValue,
|
||||
data,
|
||||
origData,
|
||||
dirtyFlags,
|
||||
blocker,
|
||||
errorMessage,
|
||||
restartNeeded
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user