Remove useMemo/useCallback across the web UI

This commit is contained in:
proddy
2026-04-27 13:24:07 +02:00
parent e39af36589
commit 1a880f14a0
53 changed files with 1940 additions and 2594 deletions

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify'; import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting'; import AppRouting from 'AppRouting';
@@ -46,19 +46,17 @@ const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en'); const [locale, setLocale] = useState<Locales>('en');
// Memoize locale initialization to prevent unnecessary re-runs useEffect(() => {
const initializeLocale = useCallback(async () => { const initializeLocale = async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale); localStorage.setItem('lang', newLocale);
setLocale(newLocale); setLocale(newLocale);
await loadLocaleAsync(newLocale); await loadLocaleAsync(newLocale);
setWasLoaded(true); setWasLoaded(true);
}, []); };
useEffect(() => {
void initializeLocale(); void initializeLocale();
}, [initializeLocale]); }, []);
if (!wasLoaded) return null; if (!wasLoaded) return null;

View File

@@ -43,7 +43,6 @@ const SignIn = memo(() => {
} }
}); });
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo( const updateLoginRequestValue = useMemo(
() => () =>
updateValue((updater) => updateValue((updater) =>
@@ -65,7 +64,7 @@ const SignIn = memo(() => {
}); });
}, [callSignIn, signInRequest, LL]); }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = useCallback(async () => { const validateAndSignIn = async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({ SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s') required: LL.IS_REQUIRED('%s')
@@ -77,7 +76,7 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false); setProcessing(false);
} }
}, [signInRequest, signIn, LL]); };
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -57,20 +57,18 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
const intervalCallback = useCallback(() => { useInterval(() => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}, [dialogOpen, numChanges, fetchEntities]); });
useInterval(intervalCallback);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
const hasEntityChanged = useCallback((ei: EntityItem) => { const hasEntityChanged = (ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -86,11 +84,9 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted || ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '') (ei.value || '') !== (ei.o_value || '')
); );
}, []); };
const entity_theme = useMemo( const entity_theme = useTheme({
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`, `,
@@ -140,11 +136,9 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const saveEntities = useCallback(async () => { const saveEntities = async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -173,26 +167,25 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}, [entities, writeEntities, LL, fetchEntities]); };
const editEntityItem = useCallback((ei: EntityItem) => { const editEntityItem = (ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchEntities]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: EntityItem) => {
(updatedItem: EntityItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => { void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating const new_data = creating
@@ -206,11 +199,9 @@ const CustomEntities = () => {
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data; return new_data;
}); });
}, };
[creating, hasEntityChanged]
);
const onDialogDup = useCallback((item: EntityItem) => { const onDialogDup = (item: EntityItem) => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -228,9 +219,9 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const addEntityItem = useCallback(() => { const addEntityItem = () => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -248,30 +239,27 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const formatValue = useCallback((value: unknown, uom: number) => { const formatValue = (value: unknown, uom: number) => {
return value === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + ? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []); };
const showHex = useCallback((value: number, digit: number) => { const showHex = (value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []); };
const filteredAndSortedEntities = useMemo( const filteredAndSortedEntities =
() =>
entities entities
?.filter((ei: EntityItem) => !ei.deleted) ?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [], .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
[entities]
);
const renderEntity = useCallback(() => { const renderEntity = () => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -328,17 +316,7 @@ const CustomEntities = () => {
)} )}
</Table> </Table>
); );
}, [ };
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -68,6 +68,7 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem); const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo( const updateFormValue = useMemo(
() => () =>
updateValue( updateValue(
@@ -105,16 +106,16 @@ const CustomEntitiesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}, };
[onClose]
);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -138,27 +139,21 @@ const CustomEntitiesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
const itemWithDeleted = { ...editItem, deleted: true }; onSave({ ...editItem, deleted: true });
onSave(itemWithDeleted); };
}, [editItem, onSave]);
const dup = useCallback(() => { const dup = () => {
onDup(editItem); onDup(editItem);
}, [editItem, onDup]); };
// Memoize UOM menu items to avoid recreating on every render const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}> <MenuItem key={val} value={i}>
{val} {val}
</MenuItem> </MenuItem>
)), ));
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -171,9 +171,7 @@ const Customizations = () => {
); );
}; };
const entities_theme = useMemo( const entities_theme = useTheme({
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
@@ -236,9 +234,7 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}), });
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -287,12 +283,11 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = useCallback((de: DeviceEntity) => { const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!'; return de.n && de.n[0] === '!';
}, []); };
const formatName = useCallback( const formatName = (de: DeviceEntity, withShortname: boolean) => {
(de: DeviceEntity, withShortname: boolean) => {
let name: string; let name: string;
if (isCommand(de)) { if (isCommand(de)) {
name = de.t name = de.t
@@ -304,9 +299,7 @@ const Customizations = () => {
name = de.t ? `${de.t} ${de.n}` : de.n || ''; name = de.t ? `${de.t} ${de.n}` : de.n || '';
} }
return withShortname ? `${name} ${de.id}` : name; return withShortname ? `${name} ${de.id}` : name;
}, };
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -336,15 +329,11 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = useCallback( const filter_entity = (de: DeviceEntity) =>
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()), formatName(de, true).toLowerCase().includes(search.toLowerCase());
[selectedFilters, search, formatName]
);
const maskDisabled = useCallback( const maskDisabled = (set: boolean) => {
(set: boolean) => {
setDeviceEntities((prev) => setDeviceEntities((prev) =>
prev.map((de) => { prev.map((de) => {
if (filter_entity(de)) { if (filter_entity(de)) {
@@ -358,11 +347,9 @@ const Customizations = () => {
return de; return de;
}) })
); );
}, };
[filter_entity]
);
const resetCustomization = useCallback(async () => { const resetCustomization = async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -372,30 +359,27 @@ const Customizations = () => {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true); setRestarting(true);
} }
}, [sendResetCustomizations, LL]); };
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
(prev) => (prev) =>
prev?.map((de) => prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? [] ) ?? []
); );
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: DeviceEntity) => {
(updatedItem: DeviceEntity) => {
setDialogOpen(false); setDialogOpen(false);
updateDeviceEntity(updatedItem); updateDeviceEntity(updatedItem);
}, };
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = (de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
return; return;
} }
@@ -406,9 +390,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const saveCustomization = useCallback(async () => { const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return; return;
} }
@@ -441,9 +425,9 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); };
const renameDevice = useCallback(async () => { const renameDevice = async () => {
await sendDeviceName({ await sendDeviceName({
id: selectedDevice, id: selectedDevice,
name: selectedDeviceName, name: selectedDeviceName,
@@ -459,14 +443,7 @@ const Customizations = () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}, [ };
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -562,10 +539,7 @@ const Customizations = () => {
</> </>
); );
const filteredEntities = useMemo( const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => { const renderDeviceData = () => {
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -57,23 +57,16 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() =>
updateValue(
setEditItem as unknown as React.Dispatch< setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>> React.SetStateAction<Record<string, unknown>>
> >
),
[]
); );
const isWriteableNumber = useMemo( const isWriteableNumber =
() =>
typeof editItem.v === 'number' && typeof editItem.v === 'number' &&
editItem.w && editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY), !(editItem.m & DeviceEntityMask.DV_READONLY);
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}, };
[onClose]
);
const save = useCallback(() => { const save = () => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
} else { } else {
onSave(editItem); onSave(editItem);
} }
}, [isWriteableNumber, editItem, onSave]); };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []); };
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue <LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`} label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n} value={editItem.n}
/> />
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} /> <LabelValue
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}> <Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { memo, useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib'; import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -77,8 +77,7 @@ const Dashboard = memo(() => {
} }
); );
const deviceValueDialogSave = useCallback( const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) { if (!selectedDashboardItem) {
return; return;
} }
@@ -94,13 +93,9 @@ const Dashboard = memo(() => {
setDeviceValueDialogOpen(false); setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined); setSelectedDashboardItem(undefined);
}); });
}, };
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useMemo( const dashboard_theme = useTheme({
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`, `,
@@ -128,9 +123,7 @@ const Dashboard = memo(() => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: [...data.nodes] },
@@ -164,19 +157,14 @@ const Dashboard = memo(() => {
} }
}); });
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => { useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll showAll
? tree.fns.onAddAll(nodeIds) // expand tree ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
const showType = useCallback( const showType = (n?: string, t?: number) => {
(n?: string, t?: number) => {
// if we have a name show it // if we have a name show it
if (n) { if (n) {
return n; return n;
@@ -197,12 +185,9 @@ const Dashboard = memo(() => {
} }
} }
return ''; return '';
}, };
[LL]
);
const showName = useCallback( const showName = (di: DashboardItem) => {
(di: DashboardItem) => {
if (di.id < 100) { if (di.id < 100) {
// if its a device (parent node) and has entities // if its a device (parent node) and has entities
if (di.nodes?.length) { if (di.nodes?.length) {
@@ -219,24 +204,17 @@ const Dashboard = memo(() => {
return <span>{di.dv.id.slice(2)}</span>; return <span>{di.dv.id.slice(2)}</span>;
} }
return null; return null;
}, };
[showType]
);
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const editDashboardValue = useCallback( const editDashboardValue = (di: DashboardItem) => {
(di: DashboardItem) => {
if (me.admin && di.dv?.c) { if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di); setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
} }
}, };
[me.admin]
);
const handleShowAll = ( const handleShowAll = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
@@ -248,10 +226,9 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = useMemo( const hasFavEntities = data.nodes.filter(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length, (item: DashboardItem) => item.id <= 90
[data.nodes] ).length;
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {

View File

@@ -4,7 +4,6 @@ import {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
@@ -133,17 +132,15 @@ const Devices = memo(() => {
}; };
}, []); }, []);
const leftOffset = useCallback(() => { const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0; if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect(); const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0; if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}, []); };
const common_theme = useMemo( const common_theme = useTheme({
() =>
useTheme({
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
@@ -165,13 +162,9 @@ const Devices = memo(() => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const device_theme = useMemo( const device_theme = useTheme([
() =>
useTheme([
common_theme, common_theme,
{ {
BaseRow: ` BaseRow: `
@@ -196,13 +189,9 @@ const Devices = memo(() => {
}, },
` `
} }
]), ]);
[common_theme]
);
const data_theme = useMemo( const data_theme = useTheme([
() =>
useTheme([
common_theme, common_theme,
{ {
Table: ` Table: `
@@ -244,9 +233,7 @@ const Devices = memo(() => {
} }
` `
} }
]), ]);
[common_theme]
);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -345,10 +332,8 @@ const Devices = memo(() => {
return sc; return sc;
}; };
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
@@ -607,13 +592,12 @@ const Devices = memo(() => {
return; return;
} }
const showDeviceValue = useCallback((dv: DeviceValue) => { const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}, []); };
const renderNameCell = useCallback( const renderNameCell = (dv: DeviceValue) => (
(dv: DeviceValue) => (
<> <>
{dv.id.slice(2)}&nbsp; {dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
@@ -626,22 +610,17 @@ const Devices = memo(() => {
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)} )}
</> </>
),
[hasMask]
); );
const shown_data = useMemo(() => { const shown_data = onlyFav
if (onlyFav) { ? deviceData.nodes.filter(
return deviceData.nodes.filter(
(dv: DeviceValue) => (dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
); )
} : deviceData.nodes.filter((dv: DeviceValue) =>
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
); );
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -52,6 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => { useEffect(() => {
@@ -61,7 +62,7 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -69,10 +70,9 @@ const DevicesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const setUom = useCallback( const setUom = (uom?: DeviceValueUOM) => {
(uom?: DeviceValueUOM) => {
if (uom === undefined) { if (uom === undefined) {
return; return;
} }
@@ -86,11 +86,9 @@ const DevicesDialog = ({
default: default:
return DeviceValueUOM_s[uom]; return DeviceValueUOM_s[uom];
} }
}, };
[LL]
);
const showHelperText = useCallback((dv: DeviceValue) => { const showHelperText = (dv: DeviceValue) => {
if (dv.h) return dv.h; if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | '); if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) { if (dv.m !== undefined && dv.x !== undefined) {
@@ -101,26 +99,16 @@ const DevicesDialog = ({
); );
} }
return undefined; return undefined;
}, []); };
const isCommand = useMemo( const isCommand = selectedItem.v === '' && selectedItem.c;
() => selectedItem.v === '' && selectedItem.c, const dialogTitle = isCommand
[selectedItem.v, selectedItem.c] ? LL.RUN_COMMAND()
); : writeable
? LL.CHANGE_VALUE()
const dialogTitle = useMemo(() => { : LL.VALUE(0);
if (isCommand) return LL.RUN_COMMAND(); const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0); const helperText = showHelperText(editItem);
}, [isCommand, writeable, LL]);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0); const valueLabel = LL.VALUE(0);

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
@@ -11,7 +9,6 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
// Available mask values
const MASK_VALUES = [ const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -20,35 +17,21 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128 DeviceEntityMask.DV_DELETED // 128
]; ];
/** const getMaskNumber = (newMask: string[]): number =>
* Converts an array of mask strings to a bitmask number newMask.reduce((mask, entry) => mask | Number(entry), 0);
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/** const getMaskString = (mask: number): string[] =>
* Converts a bitmask number to an array of mask strings MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value) String(value)
); );
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback( const handleChange = (_event: unknown, mask: string[]) => {
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask); const newMask = getMaskNumber(mask);
const updatedDe = { ...de }; const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web // If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
@@ -62,81 +45,67 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
} }
onUpdate(updatedDe); onUpdate(updatedDe);
}, };
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
return ( return (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={maskStringValue} value={getMaskString(de.m)}
onChange={handleChange} onChange={handleChange}
> >
<ToggleButton value="8" disabled={isFavoriteDisabled}> <ToggleButton
<OptionIcon type="favorite" isSet={isFavoriteSet} /> value="8"
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="4" disabled={isReadonlyDisabled}> <ToggleButton
<OptionIcon type="readonly" isSet={isReadonlySet} /> value="4"
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}> <ToggleButton
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} /> value="2"
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="1" disabled={isWebExcludeDisabled}> <ToggleButton
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} /> value="1"
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon type="deleted" isSet={isDeletedSet} /> <OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { memo, useContext, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9' bgcolor: '#72caf9'
}; };
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => { const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
@@ -72,12 +74,7 @@ const HelpComponent = () => {
}); });
const [imgError, setImgError] = useState<boolean>(false); const [imgError, setImgError] = useState<boolean>(false);
const getCustomSupportMethod = useMemo( useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) { if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as { const { Support } = event.data as {
Support: { img_url?: string; html?: string[] }; Support: { img_url?: string; html?: string[] };
@@ -100,20 +97,7 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
// Optimize API call memoization const helpLinks: HelpLink[] = [
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{ {
href: 'https://emsesp.org', href: 'https://emsesp.org',
icon: <MenuBookIcon />, icon: <MenuBookIcon />,
@@ -129,18 +113,10 @@ const HelpComponent = () => {
icon: <GitHubIcon />, icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3() label: () => LL.HELP_INFORMATION_3()
} }
], ];
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]); const imageSrc =
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return ( return (
<SectionContent> <SectionContent>
@@ -157,13 +133,13 @@ const HelpComponent = () => {
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={IMAGE_STYLES} sx={IMAGE_STYLES}
onError={handleImageError} onError={() => setImgError(true)}
src={imageSrc} src={imageSrc}
/> />
</Stack> </Stack>
)} )}
{isAdmin && ( {me?.admin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <ListItem key={href}>
@@ -191,7 +167,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={handleDownloadSystemInfo} onClick={() => void sendAPI(SYSTEM_INFO_API)}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -214,7 +190,6 @@ const HelpComponent = () => {
); );
}; };
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent); const Help = memo(HelpComponent);
export default Help; export default Help;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -69,9 +69,7 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme( const modules_theme = useTheme({
useMemo(
() => ({
Table: ` Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`, `,
@@ -111,16 +109,13 @@ const Modules = () => {
background-color: #303030; background-color: #303030;
} }
` `
}), });
[]
)
);
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const updateModuleItem = useCallback((updatedItem: ModuleItem) => { const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => { void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) => const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -128,28 +123,25 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length); setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data; return new_data;
}); });
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ModuleItem) => {
(updatedItem: ModuleItem) => {
setDialogOpen(false); setDialogOpen(false);
updateModuleItem(updatedItem); updateModuleItem(updatedItem);
}, };
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => { const editModuleItem = (mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onCancel = useCallback(async () => { const onCancel = async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchModules]); };
const saveModules = useCallback(async () => { const saveModules = async () => {
try { try {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
@@ -167,9 +159,9 @@ const Modules = () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
} }
}, [modules, updateModules, LL, fetchModules]); };
const content = useMemo(() => { const renderContent = () => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -262,22 +254,12 @@ const Modules = () => {
</Box> </Box>
</> </>
); );
}, [ };
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content} {renderContent()}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,14 +37,10 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() =>
updateValue(
setEditItem as unknown as React.Dispatch< setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>> React.SetStateAction<Record<string, unknown>>
> >
),
[]
); );
// Sync form state when dialog opens or selected item changes // Sync form state when dialog opens or selected item changes
@@ -54,18 +50,13 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = useCallback(() => { const handleSave = () => {
onSave(editItem); onSave(editItem);
}, [editItem, onSave]); };
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -132,7 +132,7 @@ const Scheduler = () => {
} }
); );
const hasScheduleChanged = useCallback((si: ScheduleItem) => { const hasScheduleChanged = (si: ScheduleItem) => {
return ( return (
si.id !== si.o_id || si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') || (si.name || '') !== (si.o_name || '') ||
@@ -143,15 +143,13 @@ const Scheduler = () => {
si.cmd !== si.o_cmd || si.cmd !== si.o_cmd ||
si.value !== si.o_value si.value !== si.o_value
); );
}, []); };
const intervalCallback = useCallback(() => { useInterval(() => {
if (numChanges === 0) { if (numChanges === 0) {
void fetchSchedule(); void fetchSchedule();
} }
}, [numChanges, fetchSchedule]); }, INTERVAL_DELAY);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { const formatter = new Intl.DateTimeFormat(locale, {
@@ -169,7 +167,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme); const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => { const saveSchedule = async () => {
try { try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
@@ -192,29 +190,28 @@ const Scheduler = () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
} }
}, [LL, schedule, updateSchedule, fetchSchedule]); };
const editScheduleItem = useCallback((si: ScheduleItem) => { const editScheduleItem = (si: ScheduleItem) => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) { if (si.o_name === undefined) {
si.o_name = si.name; si.o_name = si.name;
} }
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchSchedule]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ScheduleItem) => {
(updatedItem: ScheduleItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => { void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating const new_data = creating
@@ -227,11 +224,9 @@ const Scheduler = () => {
return new_data; return new_data;
}); });
}, };
[creating, hasScheduleChanged]
);
const addScheduleItem = useCallback(() => { const addScheduleItem = () => {
setCreating(true); setCreating(true);
const newItem: ScheduleItem = { const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,18 +234,13 @@ const Scheduler = () => {
}; };
setSelectedScheduleItem(newItem); setSelectedScheduleItem(newItem);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const filteredAndSortedSchedule = useMemo( const filteredAndSortedSchedule = schedule
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted) .filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags), .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
[schedule]
);
const dayBox = useCallback( const dayBox = (si: ScheduleItem, flag: number) => {
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2; const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag; const isActive = (si.flags & flag) === flag;
@@ -264,11 +254,9 @@ const Scheduler = () => {
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
</> </>
); );
}, };
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => { const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags]; const label = scheduleTypeLabels[si.flags];
return ( return (
@@ -278,9 +266,9 @@ const Scheduler = () => {
</Typography> </Typography>
</Box> </Box>
); );
}, []); };
const renderSchedule = useCallback(() => { const renderSchedule = () => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -343,17 +331,7 @@ const Scheduler = () => {
)} )}
</Table> </Table>
); );
}, [ };
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -60,6 +60,12 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT ScheduleFlag.SCHEDULE_SAT
] as const; ] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -84,6 +90,7 @@ const SchedulerDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo( const updateFormValue = useMemo(
() => () =>
updateValue( updateValue(
@@ -112,9 +119,7 @@ const SchedulerDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
// Helper function to handle save operations const handleSave = async (itemToSave: ScheduleItem) => {
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, itemToSave); await validate(validator, itemToSave);
@@ -122,36 +127,21 @@ const SchedulerDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, };
[validator, onSave]
);
const save = useCallback(async () => { const save = async () => {
await handleSave(editItem); await handleSave(editItem);
}, [editItem, handleSave]); };
const saveandactivate = useCallback(async () => { const saveandactivate = async () => {
await handleSave({ ...editItem, active: true }); await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]); };
const remove = useCallback(() => { const remove = () => {
onSave({ ...editItem, deleted: true }); onSave({ ...editItem, deleted: true });
}, [editItem, onSave]); };
// Optimize DOW flag conversion const DayOfWeekButton = (flag: number) => {
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag); const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag; const isSelected = (editItem.flags & flag) === flag;
return ( return (
@@ -162,21 +152,21 @@ const SchedulerDialog = ({
{dow[dayIndex]} {dow[dayIndex]}
</Typography> </Typography>
); );
}, };
[editItem.flags, dow]
);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}, };
[onClose]
);
const handleScheduleTypeChange = useCallback( const handleScheduleTypeChange = (
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => { _event: React.SyntheticEvent<HTMLElement>,
flag: ScheduleFlag | null
) => {
if (flag !== null) { if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag); setScheduleType(flag);
@@ -185,56 +175,39 @@ const SchedulerDialog = ({
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag; const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
} }
}, };
[]
);
const handleDOWChange = useCallback( const handleDOWChange = (
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => { _event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags = const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags); getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags })); setEditItem((prev) => ({ ...prev, flags: newFlags }));
}, };
[getFlagDOWnumber]
);
// Memoize derived values const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isDaySchedule = useMemo( const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
() => scheduleType === ScheduleFlag.SCHEDULE_DAY, const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
[scheduleType] const needsTimeField = isDaySchedule || isTimerSchedule;
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo( const dowFlags = getFlagDOWstring(editItem.flags);
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => { const timeFieldValue = needsTimeField
if (needsTimeField) { ? editItem.time === ''
return editItem.time === '' ? DEFAULT_TIME : editItem.time; ? DEFAULT_TIME
} : editItem.time
return editItem.time === DEFAULT_TIME ? '' : editItem.time; : editItem.time === DEFAULT_TIME
}, [editItem.time, needsTimeField]); ? ''
: editItem.time;
const timeFieldLabel = useMemo(() => { const timeFieldLabel = (() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1); return LL.TIME(1);
}, [scheduleType, LL]); })();
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useContext, useRef, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -158,18 +158,16 @@ const Sensors = () => {
} }
); );
const intervalCallback = useCallback(() => { useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]); });
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = useCallback((state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -177,7 +175,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />; return <KeyboardArrowUpOutlinedIcon />;
} }
return <UnfoldMoreOutlinedIcon />; return <UnfoldMoreOutlinedIcon />;
}, []); };
const analog_sort = useSort( const analog_sort = useSort(
{ nodes: sensorData.as }, { nodes: sensorData.as },
@@ -234,8 +232,7 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback( const formatDurationMin = (duration_min: number) => {
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE; const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY); const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
@@ -252,12 +249,9 @@ const Sensors = () => {
parts.push(LL.NUM_MINUTES({ num: minutes })); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return parts.join(' '); return parts.join(' ');
}, };
[LL]
);
const formatValue = useCallback( const formatValue = (value: unknown, uom: DeviceValueUOM) => {
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) { if (value === undefined) {
return ''; return '';
} }
@@ -286,28 +280,22 @@ const Sensors = () => {
default: default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
} }
}, };
[formatDurationMin, LL]
);
const updateTemperatureSensor = useCallback( const updateTemperatureSensor = (ts: TemperatureSensor) => {
(ts: TemperatureSensor) => {
if (me.admin) { if (me.admin) {
ts.o_n = ts.n; ts.o_n = ts.n;
setSelectedTemperatureSensor(ts); setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true); setTemperatureDialogOpen(true);
} }
}, };
[me.admin]
);
const onTemperatureDialogClose = useCallback(() => { const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const onTemperatureDialogSave = useCallback( const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ await sendTemperatureSensor({
id: ts.id, id: ts.id,
name: ts.n, name: ts.n,
@@ -325,28 +313,23 @@ const Sensors = () => {
setSelectedTemperatureSensor(undefined); setSelectedTemperatureSensor(undefined);
void fetchSensorData(); void fetchSensorData();
}); });
}, };
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = useCallback( const updateAnalogSensor = (as: AnalogSensor) => {
(as: AnalogSensor) => {
if (me.admin) { if (me.admin) {
setCreating(false); setCreating(false);
as.o_n = as.n; as.o_n = as.n;
setSelectedAnalogSensor(as); setSelectedAnalogSensor(as);
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
} }
}, };
[me.admin]
);
const onAnalogDialogClose = useCallback(() => { const onAnalogDialogClose = () => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const addAnalogSensor = useCallback(() => { const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) { if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO()); toast.error(LL.NO_GPIO());
return; return;
@@ -366,10 +349,9 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}, []); };
const onAnalogDialogSave = useCallback( const onAnalogDialogSave = async (as: AnalogSensor) => {
async (as: AnalogSensor) => {
await sendAnalogSensor({ await sendAnalogSensor({
id: as.id, id: as.id,
gpio: as.g, gpio: as.g,
@@ -392,12 +374,9 @@ const Sensors = () => {
setSelectedAnalogSensor(undefined); setSelectedAnalogSensor(undefined);
void fetchSensorData(); void fetchSensorData();
}); });
}, };
[sendAnalogSensor, LL, fetchSensorData]
);
const RenderAnalogSensors = useMemo( const RenderAnalogSensors = (
() => (
<Table <Table
data={{ nodes: sensorData.as }} data={{ nodes: sensorData.as }}
theme={analog_theme} theme={analog_theme}
@@ -443,9 +422,7 @@ const Sensors = () => {
fullWidth fullWidth
style={HEADER_BUTTON_STYLE_END} style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')} endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
> >
{LL.VALUE(0)} {LL.VALUE(0)}
</Button> </Button>
@@ -478,20 +455,9 @@ const Sensors = () => {
</> </>
)} )}
</Table> </Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
); );
const RenderTemperatureSensors = useMemo( const RenderTemperatureSensors = (
() => (
<Table <Table
data={{ nodes: sensorData.ts }} data={{ nodes: sensorData.ts }}
theme={temperature_theme} theme={temperature_theme}
@@ -544,16 +510,6 @@ const Sensors = () => {
</> </>
)} )}
</Table> </Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -53,6 +53,7 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo( const updateFormValue = useMemo(
() => () =>
updateValue((updater) => updateValue((updater) =>
@@ -66,49 +67,29 @@ const SensorsAnalogDialog = ({
[setEditItem] [setEditItem]
); );
// Memoize helper functions to check sensor type conditions const isCounterOrRate =
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE || editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
[editItem.t] const isCounter =
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
[editItem.t] const isFreqType =
); editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
const isFreqType = useMemo( const isPWM =
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 || editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 || editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2, editItem.t === AnalogType.PWM_2;
[editItem.t] const isDACOutGPIO =
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT && editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26), (editItem.g === 25 || editItem.g === 26);
[editItem.t, editItem.g] const isDigitalOutGPIO =
); editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
const analogTypeMenuItems = useMemo( name: val,
() => value: i + 1
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 })) }))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => ( .map(({ name, value }) => (
<MenuItem <MenuItem
@@ -118,19 +99,13 @@ const SensorsAnalogDialog = ({
> >
{name} {name}
</MenuItem> </MenuItem>
)), ));
[disabledTypeList]
);
const uomMenuItems = useMemo( const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}> <MenuItem key={val} value={i}>
{val} {val}
</MenuItem> </MenuItem>
)), ));
[]
);
const analogGPIOMenuItems = () => const analogGPIOMenuItems = () =>
// add selectedItem.g to the list // add selectedItem.g to the list
@@ -157,16 +132,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}, };
[onClose]
);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -174,17 +149,13 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
onSave({ ...editItem, d: true }); onSave({ ...editItem, d: true });
}, [editItem, onSave]); };
const dialogTitle = useMemo( const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -50,6 +50,7 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
// Stable handler reference so the memoized ValidatedTextField can skip re-renders
const updateFormValue = useMemo( const updateFormValue = useMemo(
() => () =>
updateValue( updateValue(
@@ -69,16 +70,13 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}, };
[onClose]
);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -86,29 +84,11 @@ const SensorsTemperatureDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -128,12 +108,23 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={offsetValue} value={numberValue(editItem.o)}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={slotProps} slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react'; import { memo, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import { import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE()); useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => { const handleSignOut = () => {
signOut(true); signOut(true);
}, [signOut]); };
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -63,22 +63,16 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize AP enabled state const apEnabled = data ? isAPEnabled(data) : false;
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
@@ -88,7 +82,7 @@ const APSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -106,49 +106,36 @@ const ApplicationSettings = () => {
}); });
}); });
// Memoized input props to prevent recreation on every render const SecondsInputProps = {
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}), };
[LL]
);
const MinutesInputProps = useMemo( const MinutesInputProps = {
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}), };
[LL]
);
const HoursInputProps = useMemo( const HoursInputProps = {
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}), };
[LL]
);
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const updateBoardProfile = useCallback( const updateBoardProfile = async (board_profile: string) => {
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => { await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message); toast.error(error.message);
}); });
}, };
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = useCallback(async () => { const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
@@ -157,10 +144,9 @@ const ApplicationSettings = () => {
} finally { } finally {
await saveData(); await saveData();
} }
}, [data, saveData]); };
const changeBoardProfile = useCallback( const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value; const boardProfile = event.target.value;
updateFormValue(event); updateFormValue(event);
if (boardProfile === 'CUSTOM') { if (boardProfile === 'CUSTOM') {
@@ -171,17 +157,14 @@ const ApplicationSettings = () => {
} else { } else {
void updateBoardProfile(boardProfile); void updateBoardProfile(boardProfile);
} }
}, };
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = useCallback(async () => { const restart = async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}, [validateAndSubmit, doRestart]); };
// Memoize board profile select items to prevent recreation const boardProfileItems = boardProfileSelectItems();
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
try { try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,16 +65,33 @@ const DownloadUpload = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
setRestarting(false); setRestarting(false);
} }
}, [sendAPI]); };
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = useCallback(() => { const handleCloseBackupDialog = () => {
setConfirmBackup(false); setConfirmBackup(false);
}, []); };
const renderBackupDialog = useMemo( const handleDownload = (type: string) => () => {
() => ( void sendExportData(type);
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Dialog <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmBackup} open={confirmBackup}
@@ -98,40 +115,13 @@ const DownloadUpload = () => {
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={() => handleDownload('systembackup')()} onClick={handleDownload('systembackup')}
color="primary" color="primary"
> >
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
),
[confirmBackup, handleCloseBackupDialog, LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{renderBackupDialog}
<Typography sx={{ pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = useCallback(() => { const sendResetMQTT = () => {
void callAction({ action: 'resetMQTT' }) void callAction({ action: 'resetMQTT' })
.then(() => { .then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful'); toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,29 +65,20 @@ const MqttSettings = () => {
.catch((error) => { .catch((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
}, []); };
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const SecondsInputProps = useMemo( const SecondsInputProps = {
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}), };
[LL]
);
const emptyFieldErrors = useMemo(() => ({}), []); const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -96,10 +87,9 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const publishIntervalFields = useMemo( const publishIntervalFields = [
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{ {
@@ -112,9 +102,7 @@ const MqttSettings = () => {
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false }, { name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false }, { name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
], ];
[LL]
);
if (!data) { if (!data) {
return ( return (
@@ -154,7 +142,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -166,7 +154,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -178,7 +166,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -219,7 +207,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -438,7 +426,7 @@ const MqttSettings = () => {
<Grid key={field.name}> <Grid key={field.name}>
{field.validated ? ( {field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name={field.name} name={field.name}
label={field.label} label={field.label}
slotProps={{ slotProps={{

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -61,14 +61,11 @@ const NTPSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems(); const timeZoneItems = useTimeZoneSelectItems();
// Memoized selected timezone value const selectedTzValue = data
const selectedTzValue = useMemo( ? selectedTimeZone(data.tz_label, data.tz_format)
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined), : undefined;
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>(''); const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
@@ -82,32 +79,22 @@ const NTPSettings = () => {
} }
); );
// Memoize updateFormValue to prevent recreation on every render const updateFormValue = updateValueDirty(
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize updateLocalTime handler const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
const updateLocalTime = useCallback( setLocalTime(event.target.value);
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
// Memoize openSetTime handler const openSetTime = () => {
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}, []); };
// Memoize configureTime handler const configureTime = async () => {
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -120,13 +107,11 @@ const NTPSettings = () => {
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}, [localTime, updateTime, LL, loadData]); };
// Memoize close dialog handler const handleCloseSetTime = () => setSettingTime(false);
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
// Memoize validate and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -135,23 +120,18 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
// Memoize timezone change handler const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
const changeTimeZone = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings, ...settings,
tz_label: event.target.value, tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value] tz_format: TIME_ZONES[event.target.value]
})); }));
updateFormValue(event); updateFormValue(event);
}, };
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders const renderContent = () => {
const renderContent = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
@@ -236,26 +216,12 @@ const NTPSettings = () => {
)} )}
</> </>
); );
}, [ };
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent} {renderContent()}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}> <Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle> <DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -43,24 +43,27 @@ const Settings = () => {
immediate: false immediate: false
}); });
const doFormat = useCallback(async () => { const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true); setRestarting(true);
setConfirmFactoryReset(false); setConfirmFactoryReset(false);
}); });
}, [sendAPI]); };
const handleFactoryResetClose = useCallback(() => { const handleFactoryResetClose = () => {
setConfirmFactoryReset(false); setConfirmFactoryReset(false);
}, []); };
const handleFactoryResetClick = useCallback(() => { const handleFactoryResetClick = () => {
setConfirmFactoryReset(true); setConfirmFactoryReset(true);
}, []); };
if (restarting) {
return <SystemMonitor />;
}
const content = useMemo(() => {
return ( return (
<> <SectionContent>
<List> <List>
<ListMenuItem <ListMenuItem
icon={TuneIcon} icon={TuneIcon}
@@ -173,18 +176,8 @@ const Settings = () => {
{LL.FACTORY_RESET()} {LL.FACTORY_RESET()}
</Button> </Button>
</Box> </Box>
</> </SectionContent>
); );
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
}; };
export default Settings; export default Settings;

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = { export const TIME_ZONES: Record<string, string> = {
@@ -472,26 +470,16 @@ export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}> <MenuItem key={label} value={label}>
{label} {label}
</MenuItem> </MenuItem>
)); ));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return precomputedTimeZoneItems; return precomputedTimeZoneItems;
} }

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { import {
Navigate, Navigate,
Route, Route,
@@ -40,26 +40,20 @@ const Network = () => {
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback( const selectNetwork = (network: WiFiNetwork) => {
(network: WiFiNetwork) => {
setSelectedNetwork(network); setSelectedNetwork(network);
void navigate('/settings/network/settings'); void navigate('/settings/network/settings');
}, };
[navigate]
);
const deselectNetwork = useCallback(() => { const deselectNetwork = () => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}, []); };
const contextValue = useMemo( const contextValue = {
() => ({
...(selectedNetwork && { selectedNetwork }), ...(selectedNetwork && { selectedNetwork }),
selectNetwork, selectNetwork,
deselectNetwork deselectNetwork
}), };
[selectedNetwork, selectNetwork, deselectNetwork]
);
return ( return (
<WiFiConnectionContext.Provider value={contextValue}> <WiFiConnectionContext.Provider value={contextValue}>

View File

@@ -121,19 +121,19 @@ const NetworkSettings = () => {
deselectNetwork(); deselectNetwork();
}, [data, saveData, deselectNetwork]); }, [data, saveData, deselectNetwork]);
const setCancel = useCallback(async () => { const setCancel = async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}, [deselectNetwork, loadData]); };
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react'; import { memo, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = useCallback(() => { const renderNetworkScanner = () => {
if (!networkList) { if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />; return <FormLoader errorMessage={errorMessage || ''} />;
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}, [networkList, errorMessage]); };
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react'; import { memo, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,8 +63,7 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = useCallback( const renderNetwork = (network: WiFiNetwork) => (
(network: WiFiNetwork) => (
<ListItem <ListItem
key={network.bssid} key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)} onClick={() => wifiConnectionContext.selectNetwork(network)}
@@ -89,8 +88,6 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
</Badge> </Badge>
</ListItemIcon> </ListItemIcon>
</ListItem> </ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {

View File

@@ -99,34 +99,28 @@ const ManageUsers = () => {
[] []
); );
const noAdminConfigured = useCallback( const noAdminConfigured = () => !data?.users.find((u) => u.admin);
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = useCallback( const removeUser = (toRemove: UserType) => {
(toRemove: UserType) => {
if (!data) return; if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username); const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users }); updateDataValue({ ...data, users });
setChanged(changed + 1); setChanged(changed + 1);
}, };
[data, updateDataValue, changed]
);
const createUser = useCallback(() => { const createUser = () => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}, []); };
const editUser = useCallback((toEdit: UserType) => { const editUser = (toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}, []); };
const cancelEditingUser = useCallback(() => { const cancelEditingUser = useCallback(() => {
setUser(undefined); setUser(undefined);
@@ -150,20 +144,20 @@ const ManageUsers = () => {
setGeneratingToken(undefined); setGeneratingToken(undefined);
}, []); }, []);
const generateTokenForUser = useCallback((username: string) => { const generateTokenForUser = (username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}, []); };
const onSubmit = useCallback(async () => { const onSubmit = async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}, [saveData, authenticatedContext]); };
const onCancelSubmit = useCallback(async () => { const onCancelSubmit = async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}, [loadData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -177,15 +171,10 @@ const ManageUsers = () => {
admin: boolean; admin: boolean;
} }
// add id to the type, needed for the table const user_table = data.users.map((u) => ({
const user_table = useMemo(
() =>
data.users.map((u) => ({
...u, ...u,
id: u.username id: u.username
})) as UserType2[], })) as UserType2[];
[data.users]
);
return ( return (
<> <>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
@@ -15,9 +15,7 @@ const Security = () => {
const location = useLocation(); const location = useLocation();
const matchedRoutes = useMemo( const matchedRoutes = matchRoutes(
() =>
matchRoutes(
[ [
{ {
path: '/settings/security/settings', path: '/settings/security/settings',
@@ -26,8 +24,6 @@ const Security = () => {
{ path: '/settings/security/users', element: <SecuritySettings /> } { path: '/settings/security/users', element: <SecuritySettings /> }
], ],
location location
),
[location]
); );
const routerTab = matchedRoutes?.[0]?.route.path || false; const routerTab = matchedRoutes?.[0]?.route.path || false;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
} }
}, [open]); }, [open]);
const validateAndDone = useCallback(async () => { const validateAndDone = async () => {
if (user) { if (user) {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
} }
}, [user, validator, onDoneEditing]); };
return ( return (
<Dialog <Dialog

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -36,9 +34,7 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme( const stats_theme = tableTheme({
useMemo(
() => ({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`, `,
@@ -74,21 +70,15 @@ const SystemActivity = () => {
text-align: center; text-align: center;
} }
` `
}), });
[]
)
);
const showName = useCallback( const showName = (id: number) => {
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] = const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES']; id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name](); return LL.STATUS_NAMES[name]();
}, };
[LL]
);
const showQuality = useCallback((stat: Stat) => { const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
@@ -100,14 +90,18 @@ const SystemActivity = () => {
} else { } else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
} }
}, []); };
const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<SectionContent>
<Table <Table
data={{ nodes: data.stats }} data={{ nodes: data.stats }}
theme={stats_theme} theme={stats_theme}
@@ -136,10 +130,8 @@ const SystemActivity = () => {
</> </>
)} )}
</Table> </Table>
</SectionContent>
); );
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; export default SystemActivity;

View File

@@ -1,4 +1,4 @@
import { type FC, memo, useMemo } from 'react'; import { type FC, memo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,16 +127,15 @@ const MqttStatus = () => {
void loadData(); void loadData();
}); });
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || ''; const errorMessage = error?.message || '';
const mqttStatusText = useMemo(() => { const mqttStatusText = !data
if (!data) return ''; ? ''
if (!data.enabled) return LL.NOT_ENABLED(); : !data.enabled
return data.connected ? LL.NOT_ENABLED()
: data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})` ? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`; : `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) { if (!data) {
return ( return (

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -67,12 +65,16 @@ const NTPStatus = () => {
} }
}; };
const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -121,10 +123,8 @@ const NTPStatus = () => {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
</SectionContent>
); );
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
}; };
export default NTPStatus; export default NTPStatus;

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite'; import GiteIcon from '@mui/icons-material/Gite';
@@ -124,9 +122,12 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL); const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
@@ -134,6 +135,7 @@ const NetworkStatus = () => {
const qualityColor = networkQualityHighlight(data, theme); const qualityColor = networkQualityHighlight(data, theme);
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
</> </>
)} )}
</List> </List>
</SectionContent>
); );
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useState } from 'react'; import { useContext, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -43,7 +43,6 @@ import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor'; import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = ( const formatDurationSec = (
@@ -97,10 +96,8 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
// Memoize derived status values to avoid recalculation on every render const busStatus = (() => {
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown'; if (!data) return 'EMS state unknown';
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -111,12 +108,10 @@ const SystemStatus = () => {
default: default:
return 'EMS state unknown'; return 'EMS state unknown';
} }
}, [data?.bus_status, data?.bus_uptime, LL]); })();
// Memoize derived status values to avoid recalculation on every render const systemStatus = (() => {
const systemStatus = useMemo(() => {
if (!data) return '??'; if (!data) return '??';
switch (data.status) { switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
@@ -129,14 +124,12 @@ const SystemStatus = () => {
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0)); return LL.GPIO_OF(LL.FAILED(0));
default: default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK'; return 'OK';
} }
}, [data?.status, LL]); })();
const busStatusHighlight = useMemo(() => { const busStatusHighlight = (() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -147,11 +140,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.bus_status, theme.palette]); })();
const ntpStatus = useMemo(() => { const ntpStatus = (() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
@@ -164,11 +156,10 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.ntp_status, data?.ntp_time, LL]); })();
const ntpStatusHighlight = useMemo(() => { const ntpStatusHighlight = (() => {
if (!data) return theme.palette.error.main; if (!data) return theme.palette.error.main;
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -179,11 +170,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
}, [data?.ntp_status, theme.palette]); })();
const networkStatusHighlight = useMemo(() => { const networkStatusHighlight = (() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -198,11 +188,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.network_status, theme.palette]); })();
const networkStatus = useMemo(() => { const networkStatus = (() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1); return LL.INACTIVE(1);
@@ -223,15 +212,12 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.network_status, data?.wifi_rssi, LL]); })();
const activeHighlight = useCallback( const activeHighlight = (value: boolean) =>
(value: boolean) => value ? theme.palette.success.main : theme.palette.info.main;
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = useCallback(async () => { const doRestart = async () => {
setConfirmRestart(false); setConfirmRestart(false);
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -239,14 +225,123 @@ const SystemStatus = () => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const handleCloseRestartDialog = useCallback(() => { const handleCloseRestartDialog = () => setConfirmRestart(false);
setConfirmRestart(false);
}, []); if (restarting) {
return <SystemMonitor />;
}
if (!data || !LL) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={`v${data.emsesp_version || ''}`}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
const renderRestartDialog = useMemo(
() => (
<Dialog <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmRestart} open={confirmRestart}
@@ -273,177 +368,8 @@ const SystemStatus = () => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
), </SectionContent>
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
); );
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={apStatusText}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
{renderRestartDialog}
</>
);
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
}; };
export default SystemStatus; export default SystemStatus;

View File

@@ -1,11 +1,4 @@
import { import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -185,8 +178,7 @@ const SystemLog = () => {
}; };
}, [data]); // Recalculate when data changes (in case layout shifts) }, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render const handleLogMessage = (message: { data: string }) => {
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data; const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry; const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => { setLogEntries((log) => {
@@ -200,7 +192,7 @@ const SystemLog = () => {
const newLog = [...log, logentry]; const newLog = [...log, logentry];
return newLog; return newLog;
}); });
}, []); };
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
@@ -211,7 +203,7 @@ const SystemLog = () => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const onDownload = useCallback(() => { const onDownload = () => {
const result = logEntries const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n'); .join('\n');
@@ -225,11 +217,11 @@ const SystemLog = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}, [logEntries]); };
const saveSettings = useCallback(async () => { const saveSettings = async () => {
await saveData(); await saveData();
}, [saveData]); };
// handle scrolling - optimized to only scroll when needed // handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -246,7 +238,7 @@ const SystemLog = () => {
} }
}, [logEntries.length, autoscroll]); }, [logEntries.length, autoscroll]);
const sendReadCommand = useCallback(() => { const sendReadCommand = () => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -257,7 +249,7 @@ const SystemLog = () => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
} }
}, [readValue, readOpen, send]); };
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
@@ -57,10 +57,9 @@ const SystemMonitor = () => {
void send(); void send();
}, 1000); // check every 1 second }, 1000); // check every 1 second
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status; const status = data?.status;
const message = const statusMessage =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE() ? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
@@ -71,25 +70,18 @@ const SystemMonitor = () => {
? 'Upload Failed' ? 'Upload Failed'
: LL.RESTARTING_POST(); : LL.RESTARTING_POST();
const uploading = const isUploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress = const progressValue =
uploading && status isUploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0; : 0;
return { const onCancel = async () => {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined); setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/'; document.location.href = '/';
}, [setSystemStatus]); };
return ( return (
<Box <Box

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { memo, useContext, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -105,9 +105,9 @@ const VersionInfoDialog = memo(
onClose onClose
}: { }: {
showVersionInfo: number; showVersionInfo: number;
latestVersion?: VersionInfo; latestVersion: VersionInfo | undefined;
latestDevVersion?: VersionInfo; latestDevVersion: VersionInfo | undefined;
partitionVersion?: VersionInfo | undefined; partitionVersion: VersionInfo | undefined;
partition: string; partition: string;
currentPartition: string; currentPartition: string;
size: number; size: number;
@@ -224,7 +224,7 @@ const VersionInfoDialog = memo(
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{version.date && ( {version && version.date && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}> <TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell <TableCell
component="th" component="th"
@@ -283,8 +283,8 @@ const InstallDialog = memo(
}: { }: {
openInstallDialog: boolean; openInstallDialog: boolean;
fetchDevVersion: boolean; fetchDevVersion: boolean;
latestVersion?: VersionInfo; latestVersion: VersionInfo | undefined;
latestDevVersion?: VersionInfo; latestDevVersion: VersionInfo | undefined;
upgradeImportantMessageType: number; upgradeImportantMessageType: number;
downloadOnly: boolean; downloadOnly: boolean;
platform: string; platform: string;
@@ -292,16 +292,14 @@ const InstallDialog = memo(
onClose: () => void; onClose: () => void;
onInstall: (url: string) => void; onInstall: (url: string) => void;
}) => { }) => {
const binURL = useMemo(() => { const binURL = (() => {
if (!latestVersion || !latestDevVersion) return ''; if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion; const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`; const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion return fetchDevVersion
? `${DEV_URL}${filename}` ? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.version}/${filename}`; : `${STABLE_URL}v${version.version}/${filename}`;
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]); })();
return ( return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}> <Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
@@ -532,18 +530,13 @@ const Version = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]); const platform = data ? getPlatform(data) : '';
const otherPartitions = useMemo( const otherPartitions =
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [], data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
[data]
);
const setPartitionVersionInfo = useCallback( const setPartitionVersionInfo = (partition: string) => {
(partition: string) => {
setShowVersionInfo(3); setShowVersionInfo(3);
// search for the partition in the data.partitions array
const partitionData = data?.partitions.find((p) => p.partition === partition); const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) { if (partitionData) {
setPartitionVersion({ setPartitionVersion({
@@ -553,77 +546,64 @@ const Version = () => {
setPartition(partitionData.partition); setPartition(partitionData.partition);
setFirmwareSize(partitionData.size); setFirmwareSize(partitionData.size);
} }
}, };
[data]
);
const doRestart = useCallback(async () => { const doRestart = async () => {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
setRestarting(true); setRestarting(true);
}, [sendAPI]); };
const installFirmwareURL = useCallback( const installFirmwareURL = async (url: string) => {
async (url: string) => {
await sendUploadURL(url).catch((error: Error) => { await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message); toast.error(error.message);
}); });
await doRestart(); await doRestart();
}, };
[sendUploadURL, doRestart]
);
const installPartitionFirmware = useCallback( const installPartitionFirmware = async (partition: string) => {
async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => { await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message); toast.error(error.message);
}); });
setRestarting(true); setRestarting(true);
}, };
[sendSetPartition]
);
const showPartitionDialog = useCallback( const showPartitionDialog = (
(version: string, partition: string, install_date: string) => { version: string,
partition: string,
install_date: string
) => {
setOpenInstallPartitionDialog(true); setOpenInstallPartitionDialog(true);
setPartitionVersion({ version: version, date: install_date }); setPartitionVersion({ version: version, date: install_date });
setPartition(partition); setPartition(partition);
}, };
[]
);
const showFirmwareDialog = useCallback( const showFirmwareDialog = (useDevVersion: boolean) => {
(useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion); setFetchDevVersion(useDevVersion);
void checkUpgradeImportantMessages( const targetVersion = useDevVersion
useDevVersion ? latestDevVersion?.version : latestVersion?.version ? latestDevVersion?.version
); : latestVersion?.version;
if (targetVersion) {
void checkUpgradeImportantMessages(targetVersion);
}
setOpenInstallDialog(true); setOpenInstallDialog(true);
}, };
[latestDevVersion, latestVersion, fetchDevVersion]
);
const closeInstallDialog = useCallback(() => { const closeInstallDialog = () => setOpenInstallDialog(false);
setOpenInstallDialog(false); const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
}, []);
const closeInstallPartitionDialog = useCallback(() => { const handleVersionInfoClose = () => {
setOpenInstallPartitionDialog(false);
}, []);
const handleVersionInfoClose = useCallback(() => {
setShowVersionInfo(0); setShowVersionInfo(0);
setPartitionVersion(undefined); setPartitionVersion(undefined);
setPartition(''); setPartition('');
}, []); };
useLayoutTitle('EMS-ESP Firmware'); useLayoutTitle('EMS-ESP Firmware');
const showButtons = useCallback( const showButtons = (showingDev: boolean) => {
(showingDev: boolean) => {
const choice = showingDev const choice = showingDev
? !usingDevVersion ? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
@@ -671,24 +651,22 @@ const Version = () => {
{choice} {choice}
</Button> </Button>
); );
}, };
[
usingDevVersion, if (restarting) {
devUpgradeAvailable, return <SystemMonitor />;
stableUpgradeAvailable, }
me.admin,
LL,
showFirmwareDialog
]
);
const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<> <SectionContent>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}> <Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary"> <Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()} {LL.THIS_VERSION()}
@@ -771,9 +749,7 @@ const Version = () => {
{otherPartitions.length > 0 && data.developer_mode && ( {otherPartitions.length > 0 && data.developer_mode && (
<> <>
<Grid size={{ xs: 4, md: 2 }}> <Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary"> <Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
{LL.STORED_VERSIONS()}
</Typography>
</Grid> </Grid>
<Grid size={{ xs: 8, md: 10 }}> <Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => ( {otherPartitions.map((partition) => (
@@ -785,10 +761,7 @@ const Version = () => {
} }
aria-label={LL.FIRMWARE_VERSION_INFO()} aria-label={LL.FIRMWARE_VERSION_INFO()}
> >
<InfoOutlinedIcon <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton> </IconButton>
<Button <Button
sx={{ ml: 0 }} sx={{ ml: 0 }}
@@ -889,39 +862,8 @@ const Version = () => {
</> </>
)} )}
</Box> </Box>
</> </SectionContent>
); );
}, [
data,
error,
loadData,
LL,
platform,
internetLive,
latestVersion,
latestDevVersion,
showVersionInfo,
locale,
openInstallDialog,
fetchDevVersion,
downloadOnly,
me.admin,
showButtons,
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart,
otherPartitions,
setPartitionVersionInfo,
showPartitionDialog,
partitionVersion,
partition,
firmwareSize,
closeInstallPartitionDialog,
installPartitionFirmware
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
}; };
export default memo(Version); export default memo(Version);

View File

@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, memo, useMemo } from 'react'; import { type FC, type PropsWithChildren, memo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
@@ -38,19 +38,18 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level]; const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level]; const palettePath = LEVEL_PALETTE_PATHS[level];
const [key, shade] = palettePath.split('.') as [ const [paletteKeyName, shade] = palettePath.split('.') as [
keyof typeof theme.palette, keyof typeof theme.palette,
string string
]; ];
const paletteKey = theme.palette[key] as unknown as Record<string, string>; const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
string,
string
>;
const backgroundColor = paletteKey[shade]; const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
return ( return (
<Box <Box
{...rest} {...rest}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo } from 'react'; import { memo, useContext } from 'react';
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
@@ -44,27 +44,14 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => { const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext); const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback( const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
async ({ target }) => { target
}) => {
const loc = target.value as Locales; const loc = target.value as Locales;
localStorage.setItem('lang', loc); localStorage.setItem('lang', loc);
await loadLocaleAsync(loc); await loadLocaleAsync(loc);
setLocale(loc); setLocale(loc);
}, };
[setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return ( return (
<TextField <TextField
@@ -76,7 +63,12 @@ const LanguageSelector = () => {
size="small" size="small"
select select
> >
{menuItems} {LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
</TextField> </TextField>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useState } from 'react'; import { memo, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => { const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev); setShowPassword((prev) => !prev);
}, []); };
return ( return (
<ValidatedTextField <ValidatedTextField

View File

@@ -18,7 +18,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME); const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation(); const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => { const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev); setMobileOpen((prev) => !prev);
}, []); }, []);
@@ -28,7 +27,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false); setMobileOpen(false);
}, [pathname]); }, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]); const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pathnames = useMemo( const pathnames = location.pathname.split('/').filter((x) => x);
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = useCallback(() => { const handleBackClick = () => {
void navigate('/' + pathnames[0]); void navigate('/' + pathnames[0]);
}, [navigate, pathnames]); };
return ( return (
<AppBar position="fixed" sx={appBarStyles}> <AppBar position="fixed" sx={appBarStyles}>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
@@ -24,9 +24,7 @@ interface LayoutDrawerProps {
} }
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
// Memoize drawer content to prevent unnecessary re-renders const drawer = (
const drawer = useMemo(
() => (
<> <>
<Toolbar disableGutters> <Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
@@ -38,8 +36,6 @@ const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
<Divider /> <Divider />
<LayoutMenu /> <LayoutMenu />
</> </>
),
[]
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react'; import { memo, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -22,9 +22,9 @@ const LayoutMenuComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const handleMenuToggle = useCallback(() => { const handleMenuToggle = () => {
setMenuOpen((prev) => !prev); setMenuOpen((prev) => !prev);
}, []); };
return ( return (
<> <>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
@@ -21,11 +21,9 @@ const LayoutMenuItemComponent = ({
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]); const selected = routeMatches(to, pathname);
// Memoize dynamic styles based on selected state const buttonStyles: SxProps<Theme> = {
const buttonStyles: SxProps<Theme> = useMemo(
() => ({
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
borderRadius: '8px', borderRadius: '8px',
@@ -43,28 +41,20 @@ const LayoutMenuItemComponent = ({
backgroundColor: '#90caf9', backgroundColor: '#90caf9',
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
} }
}), };
[selected]
);
const iconStyles: SxProps<Theme> = useMemo( const iconStyles: SxProps<Theme> = {
() => ({
color: selected ? '#90caf9' : '#9e9e9e', color: selected ? '#90caf9' : '#9e9e9e',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.1)' : 'scale(1)', transform: selected ? 'scale(1.1)' : 'scale(1)',
transitionProperty: 'color, transform' transitionProperty: 'color, transform'
}), };
[selected]
);
const textStyles: SxProps<Theme> = useMemo( const textStyles: SxProps<Theme> = {
() => ({
color: selected ? '#90caf9' : '#f5f5f5', color: selected ? '#90caf9' : '#f5f5f5',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transitionProperty: 'color, font-weight' transitionProperty: 'color, font-weight'
}), };
[selected]
);
return ( return (
<ListItemButton <ListItemButton

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react'; import { memo } from 'react';
import type { Blocker } from 'react-router'; import type { Blocker } from 'react-router';
import { import {
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const handleReset = useCallback(() => { const handleReset = () => {
blocker.reset?.(); blocker.reset?.();
}, [blocker]); };
const handleProceed = useCallback(() => { const handleProceed = () => {
blocker.proceed?.(); blocker.proceed?.();
}, [blocker]); };
return ( return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}> <Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react'; import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -16,12 +16,9 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = useCallback( const handleTabChange = (_event: unknown, path: string) => {
(_event: unknown, path: string) => {
void navigate(path); void navigate(path);
}, };
[navigate]
);
return ( return (
<Tabs <Tabs

View File

@@ -67,7 +67,6 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
// cache object to prevent re-renders
const obj = useMemo( const obj = useMemo(
() => ({ () => ({
signIn, signIn,

View File

@@ -1,34 +1,27 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
export const usePersistState = <T>( export const usePersistState = <T>(
initial_value: T, initial_value: T,
id: string id: string
): [T, (new_state: T) => void] => { ): [T, (new_state: T) => void] => {
// Set initial value - only computed once on mount const [state, setState] = useState<T>(() => {
const _initial_value = useMemo(() => {
try { try {
const local_storage_value_str = localStorage.getItem(`state:${id}`); const stored = localStorage.getItem(`state:${id}`);
// If there is a value stored in localStorage, use that if (stored) {
if (local_storage_value_str) { return JSON.parse(stored) as T;
return JSON.parse(local_storage_value_str) as T;
} }
} catch (error) { } catch (error) {
// If parsing fails, fall back to initial_value
console.warn( console.warn(
`Failed to parse localStorage value for key "state:${id}"`, `Failed to parse localStorage value for key "state:${id}"`,
error error
); );
} }
// Otherwise use initial_value that was passed to the function
return initial_value; return initial_value;
}, [id]); // initial_value intentionally omitted - only read on first mount });
const [state, setState] = useState(_initial_value);
useEffect(() => { useEffect(() => {
try { try {
const state_str = JSON.stringify(state); localStorage.setItem(`state:${id}`, JSON.stringify(state));
localStorage.setItem(`state:${id}`, state_str);
} catch (error) { } catch (error) {
console.warn( console.warn(
`Failed to save state to localStorage for key "state:${id}"`, `Failed to save state to localStorage for key "state:${id}"`,