From 1a880f14a02612842cbc620d31f8b06d262b0971 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 13:24:07 +0200 Subject: [PATCH] Remove useMemo/useCallback across the web UI --- interface/src/App.tsx | 22 +- interface/src/SignIn.tsx | 5 +- interface/src/app/main/CustomEntities.tsx | 120 ++-- .../src/app/main/CustomEntitiesDialog.tsx | 49 +- interface/src/app/main/Customizations.tsx | 140 ++-- .../src/app/main/CustomizationsDialog.tsx | 74 +- interface/src/app/main/Dashboard.tsx | 171 ++--- interface/src/app/main/Devices.tsx | 211 +++--- interface/src/app/main/DevicesDialog.tsx | 70 +- interface/src/app/main/EntityMaskToggle.tsx | 165 ++--- interface/src/app/main/Help.tsx | 77 +- interface/src/app/main/Modules.tsx | 136 ++-- interface/src/app/main/ModulesDialog.tsx | 25 +- interface/src/app/main/Scheduler.tsx | 118 ++- interface/src/app/main/SchedulerDialog.tsx | 207 +++--- interface/src/app/main/Sensors.tsx | 534 +++++++------- .../src/app/main/SensorsAnalogDialog.tsx | 133 ++-- .../src/app/main/SensorsTemperatureDialog.tsx | 55 +- interface/src/app/main/UserProfile.tsx | 6 +- interface/src/app/settings/APSettings.tsx | 24 +- .../src/app/settings/ApplicationSettings.tsx | 85 +-- interface/src/app/settings/DownloadUpload.tsx | 60 +- interface/src/app/settings/MqttSettings.tsx | 76 +- interface/src/app/settings/NTPSettings.tsx | 92 +-- interface/src/app/settings/Settings.tsx | 227 +++--- interface/src/app/settings/TZ.tsx | 20 +- .../src/app/settings/network/Network.tsx | 30 +- .../app/settings/network/NetworkSettings.tsx | 8 +- .../settings/network/WiFiNetworkScanner.tsx | 6 +- .../settings/network/WiFiNetworkSelector.tsx | 55 +- .../src/app/settings/security/ManageUsers.tsx | 53 +- .../src/app/settings/security/Security.tsx | 24 +- interface/src/app/settings/security/User.tsx | 6 +- interface/src/app/status/Activity.tsx | 58 +- interface/src/app/status/MqttStatus.tsx | 17 +- interface/src/app/status/NTPStatus.tsx | 22 +- interface/src/app/status/NetworkStatus.tsx | 30 +- interface/src/app/status/Status.tsx | 342 ++++----- interface/src/app/status/SystemLog.tsx | 26 +- interface/src/app/status/SystemMonitor.tsx | 48 +- interface/src/app/status/Version.tsx | 672 ++++++++---------- interface/src/components/MessageBox.tsx | 25 +- .../components/inputs/LanguageSelector.tsx | 38 +- .../inputs/ValidatedPasswordField.tsx | 6 +- interface/src/components/layout/Layout.tsx | 2 - .../src/components/layout/LayoutAppBar.tsx | 11 +- .../src/components/layout/LayoutDrawer.tsx | 30 +- .../src/components/layout/LayoutMenu.tsx | 6 +- .../src/components/layout/LayoutMenuItem.tsx | 74 +- .../components/routing/BlockNavigation.tsx | 10 +- .../src/components/routing/RouterTabs.tsx | 11 +- .../authentication/Authentication.tsx | 1 - interface/src/utils/usePersistState.ts | 21 +- 53 files changed, 1940 insertions(+), 2594 deletions(-) diff --git a/interface/src/App.tsx b/interface/src/App.tsx index c92e5f9cd..b2fc2749e 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { ToastContainer, Zoom } from 'react-toastify'; import AppRouting from 'AppRouting'; @@ -46,19 +46,17 @@ const App = memo(() => { const [wasLoaded, setWasLoaded] = useState(false); const [locale, setLocale] = useState('en'); - // Memoize locale initialization to prevent unnecessary re-runs - const initializeLocale = useCallback(async () => { - const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); - const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; - localStorage.setItem('lang', newLocale); - setLocale(newLocale); - await loadLocaleAsync(newLocale); - setWasLoaded(true); - }, []); - useEffect(() => { + const initializeLocale = async () => { + const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); + const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; + localStorage.setItem('lang', newLocale); + setLocale(newLocale); + await loadLocaleAsync(newLocale); + setWasLoaded(true); + }; void initializeLocale(); - }, [initializeLocale]); + }, []); if (!wasLoaded) return null; diff --git a/interface/src/SignIn.tsx b/interface/src/SignIn.tsx index c3955d286..0b59d9d70 100644 --- a/interface/src/SignIn.tsx +++ b/interface/src/SignIn.tsx @@ -43,7 +43,6 @@ const SignIn = memo(() => { } }); - // Memoize callback to prevent recreation on every render const updateLoginRequestValue = useMemo( () => updateValue((updater) => @@ -65,7 +64,7 @@ const SignIn = memo(() => { }); }, [callSignIn, signInRequest, LL]); - const validateAndSignIn = useCallback(async () => { + const validateAndSignIn = async () => { setProcessing(true); SIGN_IN_REQUEST_VALIDATOR.messages({ required: LL.IS_REQUIRED('%s') @@ -77,7 +76,7 @@ const SignIn = memo(() => { setFieldErrors((error as ValidationError).fieldErrors); setProcessing(false); } - }, [signInRequest, signIn, LL]); + }; const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); diff --git a/interface/src/app/main/CustomEntities.tsx b/interface/src/app/main/CustomEntities.tsx index 8359132ec..4e394ba7b 100644 --- a/interface/src/app/main/CustomEntities.tsx +++ b/interface/src/app/main/CustomEntities.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -57,20 +57,18 @@ const CustomEntities = () => { initialData: [] }); - const intervalCallback = useCallback(() => { + useInterval(() => { if (!dialogOpen && !numChanges) { void fetchEntities(); } - }, [dialogOpen, numChanges, fetchEntities]); - - useInterval(intervalCallback); + }); const { send: writeEntities } = useRequest( (data: Entities) => writeCustomEntities(data), { immediate: false } ); - const hasEntityChanged = useCallback((ei: EntityItem) => { + const hasEntityChanged = (ei: EntityItem) => { return ( ei.id !== ei.o_id || ei.ram !== ei.o_ram || @@ -86,21 +84,19 @@ const CustomEntities = () => { ei.deleted !== ei.o_deleted || (ei.value || '') !== (ei.o_value || '') ); - }, []); + }; - const entity_theme = useMemo( - () => - useTheme({ - Table: ` + const entity_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(1) { padding: 8px; } @@ -120,7 +116,7 @@ const CustomEntities = () => { text-align: center; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -129,7 +125,7 @@ const CustomEntities = () => { height: 36px; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -140,11 +136,9 @@ const CustomEntities = () => { background-color: #177ac9; } ` - }), - [] - ); + }); - const saveEntities = useCallback(async () => { + const saveEntities = async () => { await writeEntities({ entities: entities .filter((ei: EntityItem) => !ei.deleted) @@ -173,44 +167,41 @@ const CustomEntities = () => { await fetchEntities(); setNumChanges(0); }); - }, [entities, writeEntities, LL, fetchEntities]); + }; - const editEntityItem = useCallback((ei: EntityItem) => { + const editEntityItem = (ei: EntityItem) => { setCreating(false); setSelectedEntityItem(ei); setDialogOpen(true); - }, []); + }; - const onDialogClose = useCallback(() => { + const onDialogClose = () => { setDialogOpen(false); - }, []); + }; - const onDialogCancel = useCallback(async () => { + const onDialogCancel = async () => { await fetchEntities().then(() => { setNumChanges(0); }); - }, [fetchEntities]); + }; - const onDialogSave = useCallback( - (updatedItem: EntityItem) => { - setDialogOpen(false); - void updateState(readCustomEntities(), (data: EntityItem[]) => { - const new_data = creating - ? [ - ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), - updatedItem - ] - : data.map((ei) => - ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei - ); - setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); - return new_data; - }); - }, - [creating, hasEntityChanged] - ); + const onDialogSave = (updatedItem: EntityItem) => { + setDialogOpen(false); + void updateState(readCustomEntities(), (data: EntityItem[]) => { + const new_data = creating + ? [ + ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), + updatedItem + ] + : data.map((ei) => + ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei + ); + setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); + return new_data; + }); + }; - const onDialogDup = useCallback((item: EntityItem) => { + const onDialogDup = (item: EntityItem) => { setCreating(true); setSelectedEntityItem({ id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), @@ -228,9 +219,9 @@ const CustomEntities = () => { value: item.value }); setDialogOpen(true); - }, []); + }; - const addEntityItem = useCallback(() => { + const addEntityItem = () => { setCreating(true); setSelectedEntityItem({ id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), @@ -248,30 +239,27 @@ const CustomEntities = () => { value: '' }); setDialogOpen(true); - }, []); + }; - const formatValue = useCallback((value: unknown, uom: number) => { + const formatValue = (value: unknown, uom: number) => { return value === undefined ? '' : typeof value === 'number' ? new Intl.NumberFormat().format(value) + (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')}`; - }, []); + }; - const filteredAndSortedEntities = useMemo( - () => - entities - ?.filter((ei: EntityItem) => !ei.deleted) - .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [], - [entities] - ); + const filteredAndSortedEntities = + entities + ?.filter((ei: EntityItem) => !ei.deleted) + .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? []; - const renderEntity = useCallback(() => { + const renderEntity = () => { if (!entities) { return ( @@ -328,17 +316,7 @@ const CustomEntities = () => { )} ); - }, [ - entities, - error, - fetchEntities, - entity_theme, - editEntityItem, - LL, - filteredAndSortedEntities, - showHex, - formatValue - ]); + }; return ( diff --git a/interface/src/app/main/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index b40e4aa25..3a2cf945f 100644 --- a/interface/src/app/main/CustomEntitiesDialog.tsx +++ b/interface/src/app/main/CustomEntitiesDialog.tsx @@ -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 CancelIcon from '@mui/icons-material/Cancel'; @@ -68,6 +68,7 @@ const CustomEntitiesDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue( @@ -105,16 +106,16 @@ const CustomEntitiesDialog = ({ } }, [open, selectedItem]); - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); + const handleClose = ( + _event: React.SyntheticEvent, + reason: 'backdropClick' | 'escapeKeyDown' + ) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -138,27 +139,21 @@ const CustomEntitiesDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); + }; - const remove = useCallback(() => { - const itemWithDeleted = { ...editItem, deleted: true }; - onSave(itemWithDeleted); - }, [editItem, onSave]); + const remove = () => { + onSave({ ...editItem, deleted: true }); + }; - const dup = useCallback(() => { + const dup = () => { onDup(editItem); - }, [editItem, onDup]); + }; - // Memoize UOM menu items to avoid recreating on every render - const uomMenuItems = useMemo( - () => - DeviceValueUOM_s.map((val, i) => ( - - {val} - - )), - [] - ); + const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( + + {val} + + )); return ( diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index 435c333bb..a127f9a04 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useBlocker, useLocation } from 'react-router'; import { toast } from 'react-toastify'; @@ -171,19 +171,17 @@ const Customizations = () => { ); }; - const entities_theme = useMemo( - () => - useTheme({ - Table: ` + const entities_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(3) { text-align: right; } @@ -194,7 +192,7 @@ const Customizations = () => { text-align: right; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -206,7 +204,7 @@ const Customizations = () => { text-align: center; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -222,7 +220,7 @@ const Customizations = () => { background-color: #177ac9; } `, - Cell: ` + Cell: ` &:nth-of-type(2) { padding: 8px; } @@ -236,9 +234,7 @@ const Customizations = () => { padding-right: 8px; } ` - }), - [] - ); + }); function hasEntityChanged(de: DeviceEntity) { return ( @@ -287,26 +283,23 @@ const Customizations = () => { return value as string; } - const isCommand = useCallback((de: DeviceEntity) => { + const isCommand = (de: DeviceEntity) => { return de.n && de.n[0] === '!'; - }, []); + }; - const formatName = useCallback( - (de: DeviceEntity, withShortname: boolean) => { - let name: string; - if (isCommand(de)) { - name = de.t - ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` - : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; - } else if (de.cn && de.cn !== '') { - name = de.t ? `${de.t} ${de.cn}` : de.cn; - } else { - name = de.t ? `${de.t} ${de.n}` : de.n || ''; - } - return withShortname ? `${name} ${de.id}` : name; - }, - [LL] - ); + const formatName = (de: DeviceEntity, withShortname: boolean) => { + let name: string; + if (isCommand(de)) { + name = de.t + ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` + : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; + } else if (de.cn && de.cn !== '') { + name = de.t ? `${de.t} ${de.cn}` : de.cn; + } else { + name = de.t ? `${de.t} ${de.n}` : de.n || ''; + } + return withShortname ? `${name} ${de.id}` : name; + }; const getMaskNumber = (newMask: string[]) => { let new_mask = 0; @@ -336,33 +329,27 @@ const Customizations = () => { return new_masks; }; - const filter_entity = useCallback( - (de: DeviceEntity) => - (de.m & selectedFilters || !selectedFilters) && - formatName(de, true).toLowerCase().includes(search.toLowerCase()), - [selectedFilters, search, formatName] - ); + const filter_entity = (de: DeviceEntity) => + (de.m & selectedFilters || !selectedFilters) && + formatName(de, true).toLowerCase().includes(search.toLowerCase()); - const maskDisabled = useCallback( - (set: boolean) => { - setDeviceEntities((prev) => - prev.map((de) => { - if (filter_entity(de)) { - const excludeMask = - DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; - return { - ...de, - m: set ? de.m | excludeMask : de.m & ~excludeMask - }; - } - return de; - }) - ); - }, - [filter_entity] - ); + const maskDisabled = (set: boolean) => { + setDeviceEntities((prev) => + prev.map((de) => { + if (filter_entity(de)) { + const excludeMask = + DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; + return { + ...de, + m: set ? de.m | excludeMask : de.m & ~excludeMask + }; + } + return de; + }) + ); + }; - const resetCustomization = useCallback(async () => { + const resetCustomization = async () => { try { await sendResetCustomizations(); toast.info(LL.CUSTOMIZATIONS_RESTART()); @@ -372,30 +359,27 @@ const Customizations = () => { setConfirmReset(false); setRestarting(true); } - }, [sendResetCustomizations, LL]); + }; const onDialogClose = () => { setDialogOpen(false); }; - const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { + const updateDeviceEntity = (updatedItem: DeviceEntity) => { setDeviceEntities( (prev) => prev?.map((de) => de.id === updatedItem.id ? { ...de, ...updatedItem } : de ) ?? [] ); - }, []); + }; - const onDialogSave = useCallback( - (updatedItem: DeviceEntity) => { - setDialogOpen(false); - updateDeviceEntity(updatedItem); - }, - [updateDeviceEntity] - ); + const onDialogSave = (updatedItem: DeviceEntity) => { + setDialogOpen(false); + updateDeviceEntity(updatedItem); + }; - const editDeviceEntity = useCallback((de: DeviceEntity) => { + const editDeviceEntity = (de: DeviceEntity) => { if (de.n === undefined || (de.n && de.n[0] === '!')) { return; } @@ -406,9 +390,9 @@ const Customizations = () => { setSelectedDeviceEntity(de); setDialogOpen(true); - }, []); + }; - const saveCustomization = useCallback(async () => { + const saveCustomization = async () => { if (!devices || !deviceEntities || selectedDevice === -1) { return; } @@ -441,9 +425,9 @@ const Customizations = () => { .finally(() => { setOriginalSettings(deviceEntities); }); - }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); + }; - const renameDevice = useCallback(async () => { + const renameDevice = async () => { await sendDeviceName({ id: selectedDevice, name: selectedDeviceName, @@ -459,14 +443,7 @@ const Customizations = () => { setRename(false); await fetchCoreData(); }); - }, [ - selectedDevice, - selectedDeviceName, - selectedDeviceBrand, - sendDeviceName, - LL, - fetchCoreData - ]); + }; const renderDeviceList = () => ( <> @@ -562,10 +539,7 @@ const Customizations = () => { ); - const filteredEntities = useMemo( - () => deviceEntities.filter((de) => filter_entity(de)), - [deviceEntities, filter_entity] - ); + const filteredEntities = deviceEntities.filter((de) => filter_entity(de)); const renderDeviceData = () => { return ( diff --git a/interface/src/app/main/CustomizationsDialog.tsx b/interface/src/app/main/CustomizationsDialog.tsx index 95615ab56..9e5d68ca9 100644 --- a/interface/src/app/main/CustomizationsDialog.tsx +++ b/interface/src/app/main/CustomizationsDialog.tsx @@ -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 CloseIcon from '@mui/icons-material/Close'; @@ -57,23 +57,16 @@ const CustomizationsDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [error, setError] = useState(false); - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); - const isWriteableNumber = useMemo( - () => - typeof editItem.v === 'number' && - editItem.w && - !(editItem.m & DeviceEntityMask.DV_READONLY), - [editItem.v, editItem.w, editItem.m] - ); + const isWriteableNumber = + typeof editItem.v === 'number' && + editItem.w && + !(editItem.m & DeviceEntityMask.DV_READONLY); useEffect(() => { if (open) { @@ -82,16 +75,16 @@ const CustomizationsDialog = ({ } }, [open, selectedItem]); - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); + const handleClose = ( + _event: React.SyntheticEvent, + reason: 'backdropClick' | 'escapeKeyDown' + ) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; - const save = useCallback(() => { + const save = () => { if ( isWriteableNumber && editItem.mi && @@ -102,34 +95,31 @@ const CustomizationsDialog = ({ } else { onSave(editItem); } - }, [isWriteableNumber, editItem, onSave]); + }; - const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { + const updateDeviceEntity = (updatedItem: DeviceEntity) => { setEditItem((prev) => ({ ...prev, m: updatedItem.m })); - }, []); - - const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]); - - const writeableIcon = useMemo( - () => - editItem.w ? ( - - ) : ( - - ), - [editItem.w] - ); + }; return ( - {dialogTitle} + {`${LL.EDIT()} ${LL.ENTITY()}`} - + + ) : ( + + ) + } + /> diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx index b2aff1090..964f68e43 100644 --- a/interface/src/app/main/Dashboard.tsx +++ b/interface/src/app/main/Dashboard.tsx @@ -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 { Link } from 'react-router'; import { toast } from 'react-toastify'; @@ -77,40 +77,35 @@ const Dashboard = memo(() => { } ); - const deviceValueDialogSave = useCallback( - async (devicevalue: DeviceValue) => { - if (!selectedDashboardItem) { - return; - } - const id = selectedDashboardItem.parentNode.id; // this is the parent ID - await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) - .then(() => { - toast.success(LL.WRITE_CMD_SENT()); - }) - .catch((error: Error) => { - toast.error(error.message); - }) - .finally(() => { - setDeviceValueDialogOpen(false); - setSelectedDashboardItem(undefined); - }); - }, - [selectedDashboardItem, sendDeviceValue, LL] - ); + const deviceValueDialogSave = async (devicevalue: DeviceValue) => { + if (!selectedDashboardItem) { + return; + } + const id = selectedDashboardItem.parentNode.id; // this is the parent ID + await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) + .then(() => { + toast.success(LL.WRITE_CMD_SENT()); + }) + .catch((error: Error) => { + toast.error(error.message); + }) + .finally(() => { + setDeviceValueDialogOpen(false); + setSelectedDashboardItem(undefined); + }); + }; - const dashboard_theme = useMemo( - () => - useTheme({ - Table: ` + const dashboard_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 28px; } `, - Row: ` + Row: ` cursor: pointer; background-color: #1e1e1e; &:nth-of-type(odd) .td { @@ -120,7 +115,7 @@ const Dashboard = memo(() => { background-color: #177ac9; }, `, - BaseCell: ` + BaseCell: ` &:nth-of-type(2) { text-align: right; } @@ -128,9 +123,7 @@ const Dashboard = memo(() => { text-align: right; } ` - }), - [] - ); + }); const tree = useTree( { nodes: [...data.nodes] }, @@ -164,79 +157,64 @@ const Dashboard = memo(() => { } }); - const nodeIds = useMemo( - () => data.nodes.map((item: DashboardItem) => item.id), - [data.nodes] - ); - useEffect(() => { + const nodeIds = data.nodes.map((item: DashboardItem) => item.id); showAll ? tree.fns.onAddAll(nodeIds) // expand tree : tree.fns.onRemoveAll(); // collapse tree }, [parentNodes]); - const showType = useCallback( - (n?: string, t?: number) => { - // if we have a name show it - if (n) { - return n; + const showType = (n?: string, t?: number) => { + // if we have a name show it + if (n) { + return n; + } + if (t) { + // otherwise pick translation based on type + switch (t) { + case DeviceType.CUSTOM: + return LL.CUSTOM_ENTITIES(0); + case DeviceType.ANALOGSENSOR: + return LL.ANALOG_SENSORS(); + case DeviceType.TEMPERATURESENSOR: + return LL.TEMP_SENSORS(); + case DeviceType.SCHEDULER: + return LL.SCHEDULER(); + default: + break; } - if (t) { - // otherwise pick translation based on type - switch (t) { - case DeviceType.CUSTOM: - return LL.CUSTOM_ENTITIES(0); - case DeviceType.ANALOGSENSOR: - return LL.ANALOG_SENSORS(); - case DeviceType.TEMPERATURESENSOR: - return LL.TEMP_SENSORS(); - case DeviceType.SCHEDULER: - return LL.SCHEDULER(); - default: - break; - } - } - return ''; - }, - [LL] - ); + } + return ''; + }; - const showName = useCallback( - (di: DashboardItem) => { - if (di.id < 100) { - // if its a device (parent node) and has entities - if (di.nodes?.length) { - return ( - - -   {showType(di.n, di.t)} -  ({di.nodes?.length}) - - ); - } + const showName = (di: DashboardItem) => { + if (di.id < 100) { + // if its a device (parent node) and has entities + if (di.nodes?.length) { + return ( + + +   {showType(di.n, di.t)} +  ({di.nodes?.length}) + + ); } - if (di.dv) { - return {di.dv.id.slice(2)}; - } - return null; - }, - [showType] - ); + } + if (di.dv) { + return {di.dv.id.slice(2)}; + } + return null; + }; - const hasMask = useCallback( - (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, - [] - ); + const hasMask = (id: string, mask: number) => + (parseInt(id.slice(0, 2), 16) & mask) === mask; - const editDashboardValue = useCallback( - (di: DashboardItem) => { - if (me.admin && di.dv?.c) { - setSelectedDashboardItem(di); - setDeviceValueDialogOpen(true); - } - }, - [me.admin] - ); + const editDashboardValue = (di: DashboardItem) => { + if (me.admin && di.dv?.c) { + setSelectedDashboardItem(di); + setDeviceValueDialogOpen(true); + } + }; const handleShowAll = ( _event: React.MouseEvent, @@ -248,10 +226,9 @@ const Dashboard = memo(() => { } }; - const hasFavEntities = useMemo( - () => data.nodes.filter((item: DashboardItem) => item.id <= 90).length, - [data.nodes] - ); + const hasFavEntities = data.nodes.filter( + (item: DashboardItem) => item.id <= 90 + ).length; const renderContent = () => { if (!data) { diff --git a/interface/src/app/main/Devices.tsx b/interface/src/app/main/Devices.tsx index f21729b0e..08cd73524 100644 --- a/interface/src/app/main/Devices.tsx +++ b/interface/src/app/main/Devices.tsx @@ -4,7 +4,6 @@ import { useContext, useEffect, useLayoutEffect, - useMemo, useState } from 'react'; import { IconContext } from 'react-icons'; @@ -133,21 +132,19 @@ const Devices = memo(() => { }; }, []); - const leftOffset = useCallback(() => { + const leftOffset = () => { const devicesWindow = document.getElementById('devices-window'); if (!devicesWindow) return 0; const { left, right } = devicesWindow.getBoundingClientRect(); if (!left || !right) return 0; return left + (right - left < 400 ? 0 : 200); - }, []); + }; - const common_theme = useMemo( - () => - useTheme({ - BaseRow: ` + const common_theme = useTheme({ + BaseRow: ` font-size: 14px; `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -155,7 +152,7 @@ const Devices = memo(() => { border-bottom: 1px solid #565656; } `, - Row: ` + Row: ` cursor: pointer; background-color: #1E1E1E; .td { @@ -165,88 +162,78 @@ const Devices = memo(() => { background-color: #177ac9; } ` - }), - [] - ); + }); - const device_theme = useMemo( - () => - useTheme([ - common_theme, - { - BaseRow: ` - font-size: 15px; - .td { - height: 28px; - } - `, - Table: ` - --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; - `, - HeaderRow: ` - .th { - padding: 8px; - `, - Row: ` - &:nth-of-type(odd) .td { - background-color: #303030; - }, - &:hover .td { - background-color: #177ac9; - }, - ` - } - ]), - [common_theme] - ); - - const data_theme = useMemo( - () => - useTheme([ - common_theme, - { - Table: ` - --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; - height: auto; - max-height: 100%; - overflow-y: scroll; - ::-webkit-scrollbar { - display:none; + const device_theme = useTheme([ + common_theme, + { + BaseRow: ` + font-size: 15px; + .td { + height: 28px; } `, - BaseRow: ` - .td { - height: 32px; - } - `, - BaseCell: ` - &:nth-of-type(1) { - border-left: 1px solid #177ac9; - }, - &:nth-of-type(2) { - text-align: right; - }, - &:nth-of-type(3) { - border-right: 1px solid #177ac9; - } - `, - HeaderRow: ` - .th { - border-top: 1px solid #565656; - } - `, - Row: ` - &:nth-of-type(odd) .td { + Table: ` + --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; + `, + HeaderRow: ` + .th { + padding: 8px; + `, + Row: ` + &:nth-of-type(odd) .td { background-color: #303030; - }, - &:hover .td { - background-color: #177ac9; + }, + &:hover .td { + background-color: #177ac9; + }, + ` + } + ]); + + const data_theme = useTheme([ + common_theme, + { + Table: ` + --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; + height: auto; + max-height: 100%; + overflow-y: scroll; + ::-webkit-scrollbar { + display:none; } - ` - } - ]), - [common_theme] - ); + `, + BaseRow: ` + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(1) { + border-left: 1px solid #177ac9; + }, + &:nth-of-type(2) { + text-align: right; + }, + &:nth-of-type(3) { + border-right: 1px solid #177ac9; + } + `, + HeaderRow: ` + .th { + border-top: 1px solid #565656; + } + `, + Row: ` + &:nth-of-type(odd) .td { + background-color: #303030; + }, + &:hover .td { + background-color: #177ac9; + } + ` + } + ]); const getSortIcon = (state: State, sortKey: unknown) => { if (state.sortKey === sortKey && state.reverse) { @@ -345,10 +332,8 @@ const Devices = memo(() => { return sc; }; - const hasMask = useCallback( - (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, - [] - ); + const hasMask = (id: string, mask: number) => + (parseInt(id.slice(0, 2), 16) & mask) === mask; const handleDownloadCsv = () => { const deviceIndex = coreData.devices.findIndex( @@ -607,41 +592,35 @@ const Devices = memo(() => { return; } - const showDeviceValue = useCallback((dv: DeviceValue) => { + const showDeviceValue = (dv: DeviceValue) => { setSelectedDeviceValue(dv); setDeviceValueDialogOpen(true); - }, []); + }; - const renderNameCell = useCallback( - (dv: DeviceValue) => ( - <> - {dv.id.slice(2)}  - {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( - - )} - {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( - - )} - {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( - - )} - - ), - [hasMask] + const renderNameCell = (dv: DeviceValue) => ( + <> + {dv.id.slice(2)}  + {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( + + )} + {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( + + )} + {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( + + )} + ); - const shown_data = useMemo(() => { - if (onlyFav) { - return deviceData.nodes.filter( + const shown_data = onlyFav + ? deviceData.nodes.filter( (dv: DeviceValue) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) + ) + : deviceData.nodes.filter((dv: DeviceValue) => + dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) ); - } - return deviceData.nodes.filter((dv: DeviceValue) => - dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) - ); - }, [deviceData.nodes, onlyFav, search]); const deviceIndex = coreData.devices.findIndex( (d: Device) => d.id === device_select.state.id diff --git a/interface/src/app/main/DevicesDialog.tsx b/interface/src/app/main/DevicesDialog.tsx index fd2770e7d..89a6fae13 100644 --- a/interface/src/app/main/DevicesDialog.tsx +++ b/interface/src/app/main/DevicesDialog.tsx @@ -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 WarningIcon from '@mui/icons-material/Warning'; @@ -52,6 +52,7 @@ const DevicesDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); useEffect(() => { @@ -61,7 +62,7 @@ const DevicesDialog = ({ } }, [open, selectedItem]); - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -69,28 +70,25 @@ const DevicesDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); + }; - const setUom = useCallback( - (uom?: DeviceValueUOM) => { - if (uom === undefined) { - return; - } - switch (uom) { - case DeviceValueUOM.HOURS: - return LL.HOURS(); - case DeviceValueUOM.MINUTES: - return LL.MINUTES(); - case DeviceValueUOM.SECONDS: - return LL.SECONDS(); - default: - return DeviceValueUOM_s[uom]; - } - }, - [LL] - ); + const setUom = (uom?: DeviceValueUOM) => { + if (uom === undefined) { + return; + } + switch (uom) { + case DeviceValueUOM.HOURS: + return LL.HOURS(); + case DeviceValueUOM.MINUTES: + return LL.MINUTES(); + case DeviceValueUOM.SECONDS: + return LL.SECONDS(); + default: + return DeviceValueUOM_s[uom]; + } + }; - const showHelperText = useCallback((dv: DeviceValue) => { + const showHelperText = (dv: DeviceValue) => { if (dv.h) return dv.h; if (dv.l) return dv.l.join(' | '); if (dv.m !== undefined && dv.x !== undefined) { @@ -101,26 +99,16 @@ const DevicesDialog = ({ ); } return undefined; - }, []); + }; - const isCommand = useMemo( - () => selectedItem.v === '' && selectedItem.c, - [selectedItem.v, selectedItem.c] - ); - - const dialogTitle = useMemo(() => { - if (isCommand) return LL.RUN_COMMAND(); - return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0); - }, [isCommand, writeable, LL]); - - const buttonLabel = useMemo(() => { - return isCommand ? LL.EXECUTE() : LL.UPDATE(); - }, [isCommand, LL]); - - const helperText = useMemo( - () => showHelperText(editItem), - [editItem, showHelperText] - ); + const isCommand = selectedItem.v === '' && selectedItem.c; + const dialogTitle = isCommand + ? LL.RUN_COMMAND() + : writeable + ? LL.CHANGE_VALUE() + : LL.VALUE(0); + const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE(); + const helperText = showHelperText(editItem); const valueLabel = LL.VALUE(0); diff --git a/interface/src/app/main/EntityMaskToggle.tsx b/interface/src/app/main/EntityMaskToggle.tsx index d2304d6f1..e4bbe6176 100644 --- a/interface/src/app/main/EntityMaskToggle.tsx +++ b/interface/src/app/main/EntityMaskToggle.tsx @@ -1,5 +1,3 @@ -import { useCallback, useMemo } from 'react'; - import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import OptionIcon from './OptionIcon'; @@ -11,7 +9,6 @@ interface EntityMaskToggleProps { de: DeviceEntity; } -// Available mask values const MASK_VALUES = [ DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 @@ -20,123 +17,95 @@ const MASK_VALUES = [ DeviceEntityMask.DV_DELETED // 128 ]; -/** - * Converts an array of mask strings to a bitmask number - */ -const getMaskNumber = (newMask: string[]): number => { - return newMask.reduce((mask, entry) => mask | Number(entry), 0); -}; +const getMaskNumber = (newMask: string[]): number => + newMask.reduce((mask, entry) => mask | Number(entry), 0); -/** - * Converts a bitmask number to an array of mask strings - */ -const getMaskString = (mask: number): string[] => { - return MASK_VALUES.filter((value) => (mask & value) === value).map((value) => +const getMaskString = (mask: number): string[] => + MASK_VALUES.filter((value) => (mask & value) === value).map((value) => String(value) ); -}; -/** - * Checks if a specific mask bit is set - */ const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { - const handleChange = useCallback( - (_event: unknown, mask: string[]) => { - // Convert selected masks to a number - const newMask = getMaskNumber(mask); - const updatedDe = { ...de }; + const handleChange = (_event: unknown, mask: string[]) => { + const newMask = getMaskNumber(mask); + const updatedDe = { ...de }; - // Apply business logic for mask interactions - // If entity has no name and is set to readonly, also exclude from web - if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { - updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; - } else { - updatedDe.m = newMask; - } + // If entity has no name and is set to readonly, also exclude from web + if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { + updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; + } else { + updatedDe.m = newMask; + } - // If excluded from web, cannot be favorite - if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { - updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; - } + // If excluded from web, cannot be favorite + if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { + updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; + } - 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] - ); + onUpdate(updatedDe); + }; return ( - - + + - - + + - - + + - - + + - + ); diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index d68ffa591..ff1fe6a56 100644 --- a/interface/src/app/main/Help.tsx +++ b/interface/src/app/main/Help.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext, useMemo, useState } from 'react'; +import { memo, useContext, useState } from 'react'; import type { ReactElement } from 'react'; import { toast } from 'react-toastify'; @@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps = { bgcolor: '#72caf9' }; +const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 }; + const HelpComponent = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.HELP()); @@ -72,12 +74,7 @@ const HelpComponent = () => { }); const [imgError, setImgError] = useState(false); - const getCustomSupportMethod = useMemo( - () => callAction({ action: 'getCustomSupport' }), - [] - ); - - useRequest(getCustomSupportMethod).onSuccess((event) => { + useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => { if (event?.data && Object.keys(event.data).length !== 0) { const { Support } = event.data as { Support: { img_url?: string; html?: string[] }; @@ -100,47 +97,26 @@ const HelpComponent = () => { toast.error(String(error.error?.message || 'An error occurred')); }); - // Optimize API call memoization - const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); + const helpLinks: HelpLink[] = [ + { + href: 'https://emsesp.org', + icon: , + label: () => LL.HELP_INFORMATION_1() + }, + { + href: 'https://discord.gg/GP9DPSgeJq', + icon: , + label: () => LL.HELP_INFORMATION_2() + }, + { + href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose', + icon: , + label: () => LL.HELP_INFORMATION_3() + } + ]; - 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', - icon: , - label: () => LL.HELP_INFORMATION_1() - }, - { - href: 'https://discord.gg/GP9DPSgeJq', - icon: , - label: () => LL.HELP_INFORMATION_2() - }, - { - href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose', - icon: , - label: () => LL.HELP_INFORMATION_3() - } - ], - [LL] - ); - - const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]); - - // Memoize image source computation - const imageSrc = useMemo( - () => - imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url, - [imgError, customSupport.img_url] - ); + const imageSrc = + imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url; return ( @@ -157,13 +133,13 @@ const HelpComponent = () => { component="img" referrerPolicy="no-referrer" sx={IMAGE_STYLES} - onError={handleImageError} + onError={() => setImgError(true)} src={imageSrc} /> )} - {isAdmin && ( + {me?.admin && ( {helpLinks.map(({ href, icon, label }) => ( @@ -191,7 +167,7 @@ const HelpComponent = () => { startIcon={} variant="outlined" color="primary" - onClick={handleDownloadSystemInfo} + onClick={() => void sendAPI(SYSTEM_INFO_API)} > {LL.SUPPORT_INFORMATION(0)} @@ -214,7 +190,6 @@ const HelpComponent = () => { ); }; -// Memoize the component to prevent unnecessary re-renders const Help = memo(HelpComponent); export default Help; diff --git a/interface/src/app/main/Modules.tsx b/interface/src/app/main/Modules.tsx index 91d126f5a..82ea16079 100644 --- a/interface/src/app/main/Modules.tsx +++ b/interface/src/app/main/Modules.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -69,58 +69,53 @@ const Modules = () => { } ); - const modules_theme = useTheme( - useMemo( - () => ({ - Table: ` - --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; - `, - BaseRow: ` - font-size: 14px; - .td { - height: 32px; - } - `, - BaseCell: ` - &:nth-of-type(1) { - text-align: center; - } - `, - HeaderRow: ` - text-transform: uppercase; - background-color: black; - color: #90CAF9; - .th { - border-bottom: 1px solid #565656; - height: 36px; - } - `, - Row: ` - background-color: #1e1e1e; - position: relative; - cursor: pointer; - .td { - border-top: 1px solid #565656; - border-bottom: 1px solid #565656; - } - &:hover .td { - border-top: 1px solid #177ac9; - border-bottom: 1px solid #177ac9; - } - &:nth-of-type(odd) .td { - background-color: #303030; - } - ` - }), - [] - ) - ); + const modules_theme = useTheme({ + Table: ` + --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; + `, + BaseRow: ` + font-size: 14px; + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(1) { + text-align: center; + } + `, + HeaderRow: ` + text-transform: uppercase; + background-color: black; + color: #90CAF9; + .th { + border-bottom: 1px solid #565656; + height: 36px; + } + `, + Row: ` + background-color: #1e1e1e; + position: relative; + cursor: pointer; + .td { + border-top: 1px solid #565656; + border-bottom: 1px solid #565656; + } + &:hover .td { + border-top: 1px solid #177ac9; + border-bottom: 1px solid #177ac9; + } + &:nth-of-type(odd) .td { + background-color: #303030; + } + ` + }); - const onDialogClose = useCallback(() => { + const onDialogClose = () => { setDialogOpen(false); - }, []); + }; - const updateModuleItem = useCallback((updatedItem: ModuleItem) => { + const updateModuleItem = (updatedItem: ModuleItem) => { void updateState(readModules(), (data: ModuleItem[]) => { const new_data = data.map((mi) => mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi @@ -128,28 +123,25 @@ const Modules = () => { setNumChanges(new_data.filter(hasModulesChanged).length); return new_data; }); - }, []); + }; - const onDialogSave = useCallback( - (updatedItem: ModuleItem) => { - setDialogOpen(false); - updateModuleItem(updatedItem); - }, - [updateModuleItem] - ); + const onDialogSave = (updatedItem: ModuleItem) => { + setDialogOpen(false); + updateModuleItem(updatedItem); + }; - const editModuleItem = useCallback((mi: ModuleItem) => { + const editModuleItem = (mi: ModuleItem) => { setSelectedModuleItem(mi); setDialogOpen(true); - }, []); + }; - const onCancel = useCallback(async () => { + const onCancel = async () => { await fetchModules().then(() => { setNumChanges(0); }); - }, [fetchModules]); + }; - const saveModules = useCallback(async () => { + const saveModules = async () => { try { await Promise.all( modules.map((condensed_mi: ModuleItem) => @@ -167,9 +159,9 @@ const Modules = () => { await fetchModules(); setNumChanges(0); } - }, [modules, updateModules, LL, fetchModules]); + }; - const content = useMemo(() => { + const renderContent = () => { if (!modules) { return ( @@ -262,22 +254,12 @@ const Modules = () => { ); - }, [ - modules, - fetchModules, - error, - modules_theme, - editModuleItem, - LL, - numChanges, - onCancel, - saveModules - ]); + }; return ( {blocker ? : null} - {content} + {renderContent()} {selectedModuleItem && ( (selectedItem); - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); // Sync form state when dialog opens or selected item changes @@ -54,18 +50,13 @@ const ModulesDialog = ({ } }, [open, selectedItem]); - const handleSave = useCallback(() => { + const handleSave = () => { onSave(editItem); - }, [editItem, onSave]); - - const dialogTitle = useMemo( - () => `${LL.EDIT()} ${editItem.key}`, - [LL, editItem.key] - ); + }; return ( - {dialogTitle} + {`${LL.EDIT()} ${editItem.key}`} { } ); - const hasScheduleChanged = useCallback((si: ScheduleItem) => { + const hasScheduleChanged = (si: ScheduleItem) => { return ( si.id !== si.o_id || (si.name || '') !== (si.o_name || '') || @@ -143,15 +143,13 @@ const Scheduler = () => { si.cmd !== si.o_cmd || si.value !== si.o_value ); - }, []); + }; - const intervalCallback = useCallback(() => { + useInterval(() => { if (numChanges === 0) { void fetchSchedule(); } - }, [numChanges, fetchSchedule]); - - useInterval(intervalCallback, INTERVAL_DELAY); + }, INTERVAL_DELAY); useEffect(() => { const formatter = new Intl.DateTimeFormat(locale, { @@ -169,7 +167,7 @@ const Scheduler = () => { const schedule_theme = useTheme(scheduleTheme); - const saveSchedule = useCallback(async () => { + const saveSchedule = async () => { try { await updateSchedule({ schedule: schedule @@ -192,46 +190,43 @@ const Scheduler = () => { await fetchSchedule(); setNumChanges(0); } - }, [LL, schedule, updateSchedule, fetchSchedule]); + }; - const editScheduleItem = useCallback((si: ScheduleItem) => { + const editScheduleItem = (si: ScheduleItem) => { setCreating(false); setSelectedScheduleItem(si); setDialogOpen(true); if (si.o_name === undefined) { si.o_name = si.name; } - }, []); + }; - const onDialogClose = useCallback(() => { + const onDialogClose = () => { setDialogOpen(false); - }, []); + }; - const onDialogCancel = useCallback(async () => { + const onDialogCancel = async () => { await fetchSchedule().then(() => { setNumChanges(0); }); - }, [fetchSchedule]); + }; - const onDialogSave = useCallback( - (updatedItem: ScheduleItem) => { - setDialogOpen(false); - void updateState(readSchedule(), (data: ScheduleItem[]) => { - const new_data = creating - ? [...data, updatedItem] - : data.map((si) => - si.id === updatedItem.id ? { ...si, ...updatedItem } : si - ); + const onDialogSave = (updatedItem: ScheduleItem) => { + setDialogOpen(false); + void updateState(readSchedule(), (data: ScheduleItem[]) => { + const new_data = creating + ? [...data, updatedItem] + : data.map((si) => + si.id === updatedItem.id ? { ...si, ...updatedItem } : si + ); - setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); + setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); - return new_data; - }); - }, - [creating, hasScheduleChanged] - ); + return new_data; + }); + }; - const addScheduleItem = useCallback(() => { + const addScheduleItem = () => { setCreating(true); const newItem: ScheduleItem = { id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), @@ -239,36 +234,29 @@ const Scheduler = () => { }; setSelectedScheduleItem(newItem); setDialogOpen(true); - }, []); + }; - const filteredAndSortedSchedule = useMemo( - () => - schedule - .filter((si: ScheduleItem) => !si.deleted) - .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags), - [schedule] - ); + const filteredAndSortedSchedule = schedule + .filter((si: ScheduleItem) => !si.deleted) + .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags); - const dayBox = useCallback( - (si: ScheduleItem, flag: number) => { - const dayIndex = Math.log(flag) / LOG_2; - const isActive = (si.flags & flag) === flag; + const dayBox = (si: ScheduleItem, flag: number) => { + const dayIndex = Math.log(flag) / LOG_2; + const isActive = (si.flags & flag) === flag; - return ( - <> - - - {dow[dayIndex]} - - - - - ); - }, - [dow] - ); + return ( + <> + + + {dow[dayIndex]} + + + + + ); + }; - const scheduleType = useCallback((si: ScheduleItem) => { + const scheduleType = (si: ScheduleItem) => { const label = scheduleTypeLabels[si.flags]; return ( @@ -278,9 +266,9 @@ const Scheduler = () => { ); - }, []); + }; - const renderSchedule = useCallback(() => { + const renderSchedule = () => { if (!schedule) { return ( @@ -343,17 +331,7 @@ const Scheduler = () => { )} ); - }, [ - schedule, - error, - fetchSchedule, - filteredAndSortedSchedule, - schedule_theme, - editScheduleItem, - LL, - dayBox, - scheduleType - ]); + }; return ( diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index 27713de86..ca3ff97dd 100644 --- a/interface/src/app/main/SchedulerDialog.tsx +++ b/interface/src/app/main/SchedulerDialog.tsx @@ -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 CancelIcon from '@mui/icons-material/Cancel'; @@ -60,6 +60,12 @@ const FLAG_VALUES = [ ScheduleFlag.SCHEDULE_SAT ] 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 { open: boolean; creating: boolean; @@ -84,6 +90,7 @@ const SchedulerDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [scheduleType, setScheduleType] = useState(); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue( @@ -112,129 +119,95 @@ const SchedulerDialog = ({ } }, [open, selectedItem]); - // Helper function to handle save operations - const handleSave = useCallback( - async (itemToSave: ScheduleItem) => { - try { - setFieldErrors(undefined); - await validate(validator, itemToSave); - onSave(itemToSave); - } catch (error) { - setFieldErrors((error as ValidationError).fieldErrors); - } - }, - [validator, onSave] - ); - - const save = useCallback(async () => { - await handleSave(editItem); - }, [editItem, handleSave]); - - const saveandactivate = useCallback(async () => { - await handleSave({ ...editItem, active: true }); - }, [editItem, handleSave]); - - const remove = useCallback(() => { - onSave({ ...editItem, deleted: true }); - }, [editItem, onSave]); - - // Optimize DOW flag conversion - 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 isSelected = (editItem.flags & flag) === flag; - return ( - - {dow[dayIndex]} - - ); - }, - [editItem.flags, dow] - ); - - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); - - const handleScheduleTypeChange = useCallback( - (_event: React.SyntheticEvent, flag: ScheduleFlag | null) => { - if (flag !== null) { - setFieldErrors(undefined); // clear any validation errors - setScheduleType(flag); - // wipe the time field when changing the schedule type - // set the flags based on type - const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag; - setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); - } - }, - [] - ); - - const handleDOWChange = useCallback( - (_event: React.SyntheticEvent, flags: string[]) => { - const newFlags = - getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags); - setEditItem((prev) => ({ ...prev, flags: newFlags })); - }, - [getFlagDOWnumber] - ); - - // Memoize derived values - const isDaySchedule = useMemo( - () => scheduleType === ScheduleFlag.SCHEDULE_DAY, - [scheduleType] - ); - 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( - () => getFlagDOWstring(editItem.flags), - [editItem.flags, getFlagDOWstring] - ); - - const timeFieldValue = useMemo(() => { - if (needsTimeField) { - return editItem.time === '' ? DEFAULT_TIME : editItem.time; + const handleSave = async (itemToSave: ScheduleItem) => { + try { + setFieldErrors(undefined); + await validate(validator, itemToSave); + onSave(itemToSave); + } catch (error) { + setFieldErrors((error as ValidationError).fieldErrors); } - return editItem.time === DEFAULT_TIME ? '' : editItem.time; - }, [editItem.time, needsTimeField]); + }; - const timeFieldLabel = useMemo(() => { + const save = async () => { + await handleSave(editItem); + }; + + const saveandactivate = async () => { + await handleSave({ ...editItem, active: true }); + }; + + const remove = () => { + onSave({ ...editItem, deleted: true }); + }; + + const DayOfWeekButton = (flag: number) => { + const dayIndex = Math.log2(flag); + const isSelected = (editItem.flags & flag) === flag; + return ( + + {dow[dayIndex]} + + ); + }; + + const handleClose = ( + _event: React.SyntheticEvent, + reason: 'backdropClick' | 'escapeKeyDown' + ) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; + + const handleScheduleTypeChange = ( + _event: React.SyntheticEvent, + flag: ScheduleFlag | null + ) => { + if (flag !== null) { + setFieldErrors(undefined); // clear any validation errors + setScheduleType(flag); + // wipe the time field when changing the schedule type + // set the flags based on type + const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag; + setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); + } + }; + + const handleDOWChange = ( + _event: React.SyntheticEvent, + flags: string[] + ) => { + const newFlags = + getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags); + setEditItem((prev) => ({ ...prev, flags: newFlags })); + }; + + const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY; + const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER; + const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE; + const needsTimeField = isDaySchedule || isTimerSchedule; + + const dowFlags = getFlagDOWstring(editItem.flags); + + const timeFieldValue = needsTimeField + ? editItem.time === '' + ? DEFAULT_TIME + : editItem.time + : editItem.time === DEFAULT_TIME + ? '' + : editItem.time; + + const timeFieldLabel = (() => { if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); return LL.TIME(1); - }, [scheduleType, LL]); + })(); return ( diff --git a/interface/src/app/main/Sensors.tsx b/interface/src/app/main/Sensors.tsx index 028c362fc..b19a5c146 100644 --- a/interface/src/app/main/Sensors.tsx +++ b/interface/src/app/main/Sensors.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; @@ -158,18 +158,16 @@ const Sensors = () => { } ); - const intervalCallback = useCallback(() => { + useInterval(() => { if (!temperatureDialogOpen && !analogDialogOpen) { void fetchSensorData(); } - }, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]); - - useInterval(intervalCallback); + }); const temperature_theme = useTheme([common_theme, temperature_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) { return ; } @@ -177,7 +175,7 @@ const Sensors = () => { return ; } return ; - }, []); + }; const analog_sort = useSort( { nodes: sensorData.as }, @@ -234,119 +232,104 @@ const Sensors = () => { useLayoutTitle(LL.SENSORS()); - const formatDurationMin = useCallback( - (duration_min: number) => { - const totalMs = duration_min * MS_PER_MINUTE; - const days = Math.trunc(totalMs / MS_PER_DAY); - const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; - const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60; + const formatDurationMin = (duration_min: number) => { + const totalMs = duration_min * MS_PER_MINUTE; + const days = Math.trunc(totalMs / MS_PER_DAY); + const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; + const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60; - const parts: string[] = []; - if (days > 0) { - parts.push(LL.NUM_DAYS({ num: days })); - } - if (hours > 0) { - parts.push(LL.NUM_HOURS({ num: hours })); - } - if (minutes > 0) { - parts.push(LL.NUM_MINUTES({ num: minutes })); - } - return parts.join(' '); - }, - [LL] - ); + const parts: string[] = []; + if (days > 0) { + parts.push(LL.NUM_DAYS({ num: days })); + } + if (hours > 0) { + parts.push(LL.NUM_HOURS({ num: hours })); + } + if (minutes > 0) { + parts.push(LL.NUM_MINUTES({ num: minutes })); + } + return parts.join(' '); + }; - const formatValue = useCallback( - (value: unknown, uom: DeviceValueUOM) => { - if (value === undefined) { - return ''; - } - if (typeof value !== 'number') { - return value as string; - } - switch (uom) { - case DeviceValueUOM.HOURS: - return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); - case DeviceValueUOM.MINUTES: - return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); - case DeviceValueUOM.SECONDS: - return LL.NUM_SECONDS({ num: value }); - case DeviceValueUOM.NONE: - return new Intl.NumberFormat().format(value); - case DeviceValueUOM.DEGREES: - case DeviceValueUOM.DEGREES_R: - case DeviceValueUOM.FAHRENHEIT: - return ( - new Intl.NumberFormat(undefined, { - minimumFractionDigits: 1 - }).format(value) + - ' ' + - DeviceValueUOM_s[uom] - ); - default: - return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; - } - }, - [formatDurationMin, LL] - ); + const formatValue = (value: unknown, uom: DeviceValueUOM) => { + if (value === undefined) { + return ''; + } + if (typeof value !== 'number') { + return value as string; + } + switch (uom) { + case DeviceValueUOM.HOURS: + return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); + case DeviceValueUOM.MINUTES: + return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); + case DeviceValueUOM.SECONDS: + return LL.NUM_SECONDS({ num: value }); + case DeviceValueUOM.NONE: + return new Intl.NumberFormat().format(value); + case DeviceValueUOM.DEGREES: + case DeviceValueUOM.DEGREES_R: + case DeviceValueUOM.FAHRENHEIT: + return ( + new Intl.NumberFormat(undefined, { + minimumFractionDigits: 1 + }).format(value) + + ' ' + + DeviceValueUOM_s[uom] + ); + default: + return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; + } + }; - const updateTemperatureSensor = useCallback( - (ts: TemperatureSensor) => { - if (me.admin) { - ts.o_n = ts.n; - setSelectedTemperatureSensor(ts); - setTemperatureDialogOpen(true); - } - }, - [me.admin] - ); + const updateTemperatureSensor = (ts: TemperatureSensor) => { + if (me.admin) { + ts.o_n = ts.n; + setSelectedTemperatureSensor(ts); + setTemperatureDialogOpen(true); + } + }; - const onTemperatureDialogClose = useCallback(() => { + const onTemperatureDialogClose = () => { setTemperatureDialogOpen(false); void fetchSensorData(); - }, [fetchSensorData]); + }; - const onTemperatureDialogSave = useCallback( - async (ts: TemperatureSensor) => { - await sendTemperatureSensor({ - id: ts.id, - name: ts.n, - offset: ts.o, - is_system: ts.s + const onTemperatureDialogSave = async (ts: TemperatureSensor) => { + await sendTemperatureSensor({ + id: ts.id, + name: ts.n, + offset: ts.o, + is_system: ts.s + }) + .then(() => { + toast.success(LL.UPDATED_OF(LL.SENSOR(1))); }) - .then(() => { - toast.success(LL.UPDATED_OF(LL.SENSOR(1))); - }) - .catch(() => { - toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); - }) - .finally(() => { - setTemperatureDialogOpen(false); - setSelectedTemperatureSensor(undefined); - void fetchSensorData(); - }); - }, - [sendTemperatureSensor, LL, fetchSensorData] - ); + .catch(() => { + toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); + }) + .finally(() => { + setTemperatureDialogOpen(false); + setSelectedTemperatureSensor(undefined); + void fetchSensorData(); + }); + }; - const updateAnalogSensor = useCallback( - (as: AnalogSensor) => { - if (me.admin) { - setCreating(false); - as.o_n = as.n; - setSelectedAnalogSensor(as); - setAnalogDialogOpen(true); - } - }, - [me.admin] - ); + const updateAnalogSensor = (as: AnalogSensor) => { + if (me.admin) { + setCreating(false); + as.o_n = as.n; + setSelectedAnalogSensor(as); + setAnalogDialogOpen(true); + } + }; - const onAnalogDialogClose = useCallback(() => { + const onAnalogDialogClose = () => { setAnalogDialogOpen(false); void fetchSensorData(); - }, [fetchSensorData]); + }; - const addAnalogSensor = useCallback(() => { + const addAnalogSensor = () => { if (firstAvailableGPIO.current === undefined) { toast.error(LL.NO_GPIO()); return; @@ -366,194 +349,167 @@ const Sensors = () => { o_n: '' }); setAnalogDialogOpen(true); - }, []); + }; - const onAnalogDialogSave = useCallback( - async (as: AnalogSensor) => { - await sendAnalogSensor({ - id: as.id, - gpio: as.g, - name: as.n, - offset: as.o, - factor: as.f, - uom: as.u, - type: as.t, - deleted: as.d, - is_system: as.s + const onAnalogDialogSave = async (as: AnalogSensor) => { + await sendAnalogSensor({ + id: as.id, + gpio: as.g, + name: as.n, + offset: as.o, + factor: as.f, + uom: as.u, + type: as.t, + deleted: as.d, + is_system: as.s + }) + .then(() => { + toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); }) - .then(() => { - toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); - }) - .catch(() => { - toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); - }) - .finally(() => { - setAnalogDialogOpen(false); - setSelectedAnalogSensor(undefined); - void fetchSensorData(); - }); - }, - [sendAnalogSensor, LL, fetchSensorData] + .catch(() => { + toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); + }) + .finally(() => { + setAnalogDialogOpen(false); + setSelectedAnalogSensor(undefined); + void fetchSensorData(); + }); + }; + + const RenderAnalogSensors = ( + + {(tableList: AnalogSensor[]) => ( + <> +
+ + + + + + + + + + + + + + +
+ + {tableList.map((as: AnalogSensor) => ( + updateAnalogSensor(as)} + > + {as.g} + {as.n} + {AnalogTypeNames[as.t - 1]} + {(as.t === AnalogType.DIGITAL_OUT && + as.g !== GPIO_25 && + as.g !== GPIO_26) || + as.t === AnalogType.DIGITAL_IN || + as.t === AnalogType.PULSE ? ( + {as.v ? LL.ON() : LL.OFF()} + ) : ( + {formatValue(as.v, as.u)} + )} + + ))} + + + )} +
); - const RenderAnalogSensors = useMemo( - () => ( - - {(tableList: AnalogSensor[]) => ( - <> -
- - - - - - - - - - - - - - -
- - {tableList.map((as: AnalogSensor) => ( - updateAnalogSensor(as)} + const RenderTemperatureSensors = ( +
+ {(tableList: TemperatureSensor[]) => ( + <> +
+ + +
- ), - [ - analog_sort, - analog_theme, - getSortIcon, - sensorData.as, - LL, - updateAnalogSensor, - formatValue - ] - ); - - const RenderTemperatureSensors = useMemo( - () => ( - - {(tableList: TemperatureSensor[]) => ( - <> -
- - - - - - - - -
- - {tableList.map((ts: TemperatureSensor) => ( - updateTemperatureSensor(ts)} + {LL.NAME(0)} + + + +
- ), - [ - temperature_sort, - temperature_theme, - getSortIcon, - sensorData.ts, - LL, - updateTemperatureSensor, - formatValue - ] + {LL.VALUE(0)} + + + + + + {tableList.map((ts: TemperatureSensor) => ( + updateTemperatureSensor(ts)} + > + {ts.n} + {formatValue(ts.t, ts.u)} + + ))} + + + )} + ); return ( diff --git a/interface/src/app/main/SensorsAnalogDialog.tsx b/interface/src/app/main/SensorsAnalogDialog.tsx index ffc5a4485..283a00803 100644 --- a/interface/src/app/main/SensorsAnalogDialog.tsx +++ b/interface/src/app/main/SensorsAnalogDialog.tsx @@ -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 DoneIcon from '@mui/icons-material/Done'; @@ -53,6 +53,7 @@ const SensorsAnalogDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue((updater) => @@ -66,71 +67,45 @@ const SensorsAnalogDialog = ({ [setEditItem] ); - // Memoize helper functions to check sensor type conditions - const isCounterOrRate = useMemo( - () => - editItem.t === AnalogType.COUNTER || - editItem.t === AnalogType.RATE || - (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), - [editItem.t] - ); - const isCounter = useMemo( - () => - editItem.t === AnalogType.COUNTER || - (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), - [editItem.t] - ); - const isFreqType = useMemo( - () => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2, - [editItem.t] - ); - const isPWM = useMemo( - () => - editItem.t === AnalogType.PWM_0 || - editItem.t === AnalogType.PWM_1 || - editItem.t === AnalogType.PWM_2, - [editItem.t] - ); - const isDACOutGPIO = useMemo( - () => - editItem.t === AnalogType.DIGITAL_OUT && - (editItem.g === 25 || editItem.g === 26), - [editItem.t, editItem.g] - ); - const isDigitalOutGPIO = useMemo( - () => - editItem.t === AnalogType.DIGITAL_OUT && - editItem.g !== 25 && - editItem.g !== 26, - [editItem.t, editItem.g] - ); + const isCounterOrRate = + editItem.t === AnalogType.COUNTER || + editItem.t === AnalogType.RATE || + (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); + const isCounter = + editItem.t === AnalogType.COUNTER || + (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); + const isFreqType = + editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2; + const isPWM = + editItem.t === AnalogType.PWM_0 || + editItem.t === AnalogType.PWM_1 || + editItem.t === AnalogType.PWM_2; + const isDACOutGPIO = + editItem.t === AnalogType.DIGITAL_OUT && + (editItem.g === 25 || editItem.g === 26); + const isDigitalOutGPIO = + editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26; - // Memoize menu items to avoid recreation on each render - const analogTypeMenuItems = useMemo( - () => - AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 })) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(({ name, value }) => ( - - {name} - - )), - [disabledTypeList] - ); + const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({ + name: val, + value: i + 1 + })) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(({ name, value }) => ( + + {name} + + )); - const uomMenuItems = useMemo( - () => - DeviceValueUOM_s.map((val, i) => ( - - {val} - - )), - [] - ); + const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( + + {val} + + )); const analogGPIOMenuItems = () => // add selectedItem.g to the list @@ -157,16 +132,16 @@ const SensorsAnalogDialog = ({ } }, [open, selectedItem]); - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); + const handleClose = ( + _event: React.SyntheticEvent, + reason: 'backdropClick' | 'escapeKeyDown' + ) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -174,17 +149,13 @@ const SensorsAnalogDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); + }; - const remove = useCallback(() => { + const remove = () => { onSave({ ...editItem, d: true }); - }, [editItem, onSave]); + }; - const dialogTitle = useMemo( - () => - `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`, - [creating, LL] - ); + const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`; return ( diff --git a/interface/src/app/main/SensorsTemperatureDialog.tsx b/interface/src/app/main/SensorsTemperatureDialog.tsx index 670b34244..21a422a77 100644 --- a/interface/src/app/main/SensorsTemperatureDialog.tsx +++ b/interface/src/app/main/SensorsTemperatureDialog.tsx @@ -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 DoneIcon from '@mui/icons-material/Done'; @@ -50,6 +50,7 @@ const SensorsTemperatureDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue( @@ -69,16 +70,13 @@ const SensorsTemperatureDialog = ({ } }, [open, selectedItem]); - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason?: string) => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); + const handleClose = (_event: React.SyntheticEvent, reason?: string) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -86,29 +84,11 @@ const SensorsTemperatureDialog = ({ } catch (error) { 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: {TEMP_UNIT} - }, - htmlInput: { - min: OFFSET_MIN, - max: OFFSET_MAX, - step: OFFSET_STEP - } - }), - [] - ); + }; return ( - {dialogTitle} + {`${LL.EDIT()} ${LL.TEMP_SENSOR()}`} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id} @@ -128,12 +108,23 @@ const SensorsTemperatureDialog = ({ {TEMP_UNIT} + ) + }, + htmlInput: { + min: OFFSET_MIN, + max: OFFSET_MAX, + step: OFFSET_STEP + } + }} />
diff --git a/interface/src/app/main/UserProfile.tsx b/interface/src/app/main/UserProfile.tsx index 46928e239..ebcae0be3 100644 --- a/interface/src/app/main/UserProfile.tsx +++ b/interface/src/app/main/UserProfile.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext } from 'react'; +import { memo, useContext } from 'react'; import PersonIcon from '@mui/icons-material/Person'; import { @@ -23,9 +23,9 @@ const UserProfileComponent = () => { useLayoutTitle(LL.USER_PROFILE()); - const handleSignOut = useCallback(() => { + const handleSignOut = () => { signOut(true); - }, [signOut]); + }; return ( diff --git a/interface/src/app/settings/APSettings.tsx b/interface/src/app/settings/APSettings.tsx index 7d8c8875c..257cc0d81 100644 --- a/interface/src/app/settings/APSettings.tsx +++ b/interface/src/app/settings/APSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import WarningIcon from '@mui/icons-material/Warning'; @@ -63,22 +63,16 @@ const APSettings = () => { const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = useMemo( - () => - updateValueDirty( - origData as unknown as Record, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void - ), - [origData, dirtyFlags, setDirtyFlags, updateDataValue] + const updateFormValue = updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void ); - // Memoize AP enabled state - const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]); + const apEnabled = data ? isAPEnabled(data) : false; - // Memoize validation and submit handler - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { if (!data) return; try { @@ -88,7 +82,7 @@ const APSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; const content = () => { if (!data) { diff --git a/interface/src/app/settings/ApplicationSettings.tsx b/interface/src/app/settings/ApplicationSettings.tsx index ce985530b..2135a66f6 100644 --- a/interface/src/app/settings/ApplicationSettings.tsx +++ b/interface/src/app/settings/ApplicationSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -106,49 +106,36 @@ const ApplicationSettings = () => { }); }); - // Memoized input props to prevent recreation on every render - const SecondsInputProps = useMemo( - () => ({ - endAdornment: {LL.SECONDS()} - }), - [LL] - ); + const SecondsInputProps = { + endAdornment: {LL.SECONDS()} + }; - const MinutesInputProps = useMemo( - () => ({ - endAdornment: {LL.MINUTES()} - }), - [LL] - ); + const MinutesInputProps = { + endAdornment: {LL.MINUTES()} + }; - const HoursInputProps = useMemo( - () => ({ - endAdornment: {LL.HOURS()} - }), - [LL] - ); + const HoursInputProps = { + endAdornment: {LL.HOURS()} + }; - const doRestart = useCallback(async () => { + const doRestart = async () => { setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); - }, [sendAPI]); + }; - const updateBoardProfile = useCallback( - async (board_profile: string) => { - await readBoardProfile(board_profile).catch((error: Error) => { - toast.error(error.message); - }); - }, - [readBoardProfile] - ); + const updateBoardProfile = async (board_profile: string) => { + await readBoardProfile(board_profile).catch((error: Error) => { + toast.error(error.message); + }); + }; useLayoutTitle(LL.APPLICATION()); - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { try { setFieldErrors(undefined); await validate(createSettingsValidator(data), data); @@ -157,31 +144,27 @@ const ApplicationSettings = () => { } finally { await saveData(); } - }, [data, saveData]); + }; - const changeBoardProfile = useCallback( - (event: React.ChangeEvent) => { - const boardProfile = event.target.value; - updateFormValue(event); - if (boardProfile === 'CUSTOM') { - updateDataValue({ - ...data, - board_profile: boardProfile - }); - } else { - void updateBoardProfile(boardProfile); - } - }, - [data, updateBoardProfile, updateFormValue, updateDataValue] - ); + const changeBoardProfile = (event: React.ChangeEvent) => { + const boardProfile = event.target.value; + updateFormValue(event); + if (boardProfile === 'CUSTOM') { + updateDataValue({ + ...data, + board_profile: boardProfile + }); + } else { + void updateBoardProfile(boardProfile); + } + }; - const restart = useCallback(async () => { + const restart = async () => { await validateAndSubmit(); await doRestart(); - }, [validateAndSubmit, doRestart]); + }; - // Memoize board profile select items to prevent recreation - const boardProfileItems = useMemo(() => boardProfileSelectItems(), []); + const boardProfileItems = boardProfileSelectItems(); const content = () => { if (!data || !hardwareData) { diff --git a/interface/src/app/settings/DownloadUpload.tsx b/interface/src/app/settings/DownloadUpload.tsx index 02a373e12..b47b7f242 100644 --- a/interface/src/app/settings/DownloadUpload.tsx +++ b/interface/src/app/settings/DownloadUpload.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -57,7 +57,7 @@ const DownloadUpload = () => { const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); - const doRestart = useCallback(async () => { + const doRestart = async () => { setRestarting(true); try { await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); @@ -65,16 +65,33 @@ const DownloadUpload = () => { toast.error((error as Error).message); setRestarting(false); } - }, [sendAPI]); + }; useLayoutTitle(LL.DOWNLOAD_UPLOAD()); - const handleCloseBackupDialog = useCallback(() => { + const handleCloseBackupDialog = () => { setConfirmBackup(false); - }, []); + }; - const renderBackupDialog = useMemo( - () => ( + const handleDownload = (type: string) => () => { + void sendExportData(type); + setConfirmBackup(false); + }; + + if (restarting) { + return ; + } + + if (!data) { + return ( + + + + ); + } + + return ( + { - ), - [confirmBackup, handleCloseBackupDialog, LL] - ); - - const handleDownload = useCallback( - (type: string) => () => { - void sendExportData(type); - setConfirmBackup(false); - }, - [sendExportData] - ); - - if (restarting) { - return ; - } - - if (!data) { - return ( - - - - ); - } - - return ( - - {renderBackupDialog} {LL.DOWNLOAD(0)} diff --git a/interface/src/app/settings/MqttSettings.tsx b/interface/src/app/settings/MqttSettings.tsx index 417fa8190..a1e8efe18 100644 --- a/interface/src/app/settings/MqttSettings.tsx +++ b/interface/src/app/settings/MqttSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -57,7 +57,7 @@ const MqttSettings = () => { const [fieldErrors, setFieldErrors] = useState(); - const sendResetMQTT = useCallback(() => { + const sendResetMQTT = () => { void callAction({ action: 'resetMQTT' }) .then(() => { toast.success('MQTT ' + LL.REFRESH() + ' successful'); @@ -65,29 +65,20 @@ const MqttSettings = () => { .catch((error) => { toast.error(String(error.error?.message || 'An error occurred')); }); - }, []); + }; - const updateFormValue = useMemo( - () => - updateValueDirty( - origData as unknown as Record, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void - ), - [origData, dirtyFlags, setDirtyFlags, updateDataValue] + const updateFormValue = updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void ); - const SecondsInputProps = useMemo( - () => ({ - endAdornment: {LL.SECONDS()} - }), - [LL] - ); + const SecondsInputProps = { + endAdornment: {LL.SECONDS()} + }; - const emptyFieldErrors = useMemo(() => ({}), []); - - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { if (!data) return; try { setFieldErrors(undefined); @@ -96,25 +87,22 @@ const MqttSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; - const publishIntervalFields = useMemo( - () => [ - { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, - { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, - { - name: 'publish_time_thermostat', - label: LL.MQTT_INT_THERMOSTATS(), - validated: false - }, - { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, - { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), 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_other', label: LL.DEFAULT(0), validated: false } - ], - [LL] - ); + const publishIntervalFields = [ + { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, + { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, + { + name: 'publish_time_thermostat', + label: LL.MQTT_INT_THERMOSTATS(), + validated: false + }, + { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, + { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), 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_other', label: LL.DEFAULT(0), validated: false } + ]; if (!data) { return ( @@ -154,7 +142,7 @@ const MqttSettings = () => { { { { { {field.validated ? ( { const { LL } = useI18nContext(); useLayoutTitle('NTP'); - // Memoized timezone select items for better performance const timeZoneItems = useTimeZoneSelectItems(); - // Memoized selected timezone value - const selectedTzValue = useMemo( - () => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined), - [data?.tz_label, data?.tz_format] - ); + const selectedTzValue = data + ? selectedTimeZone(data.tz_label, data.tz_format) + : undefined; const [localTime, setLocalTime] = useState(''); const [settingTime, setSettingTime] = useState(false); @@ -82,32 +79,22 @@ const NTPSettings = () => { } ); - // Memoize updateFormValue to prevent recreation on every render - const updateFormValue = useMemo( - () => - updateValueDirty( - origData as unknown as Record, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void - ), - [origData, dirtyFlags, setDirtyFlags, updateDataValue] + const updateFormValue = updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void ); - // Memoize updateLocalTime handler - const updateLocalTime = useCallback( - (event: React.ChangeEvent) => setLocalTime(event.target.value), - [] - ); + const updateLocalTime = (event: React.ChangeEvent) => + setLocalTime(event.target.value); - // Memoize openSetTime handler - const openSetTime = useCallback(() => { + const openSetTime = () => { setLocalTime(formatLocalDateTime(new Date())); setSettingTime(true); - }, []); + }; - // Memoize configureTime handler - const configureTime = useCallback(async () => { + const configureTime = async () => { setProcessing(true); try { @@ -120,13 +107,11 @@ const NTPSettings = () => { } finally { setProcessing(false); } - }, [localTime, updateTime, LL, loadData]); + }; - // Memoize close dialog handler - const handleCloseSetTime = useCallback(() => setSettingTime(false), []); + const handleCloseSetTime = () => setSettingTime(false); - // Memoize validate and submit handler - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { if (!data) return; try { setFieldErrors(undefined); @@ -135,23 +120,18 @@ const NTPSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; - // Memoize timezone change handler - const changeTimeZone = useCallback( - (event: React.ChangeEvent) => { - void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ - ...settings, - tz_label: event.target.value, - tz_format: TIME_ZONES[event.target.value] - })); - updateFormValue(event); - }, - [updateFormValue] - ); + const changeTimeZone = (event: React.ChangeEvent) => { + void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ + ...settings, + tz_label: event.target.value, + tz_format: TIME_ZONES[event.target.value] + })); + updateFormValue(event); + }; - // Memoize render content to prevent unnecessary re-renders - const renderContent = useMemo(() => { + const renderContent = () => { if (!data) { return ; } @@ -236,26 +216,12 @@ const NTPSettings = () => { )} ); - }, [ - data, - errorMessage, - loadData, - updateFormValue, - fieldErrors, - selectedTzValue, - changeTimeZone, - timeZoneItems, - dirtyFlags, - openSetTime, - saving, - validateAndSubmit, - LL - ]); + }; return ( {blocker ? : null} - {renderContent} + {renderContent()} {LL.SET_TIME(1)} diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index a8efc8c4b..7a31f66e7 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -43,148 +43,141 @@ const Settings = () => { immediate: false }); - const doFormat = useCallback(async () => { + const doFormat = async () => { await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { setRestarting(true); setConfirmFactoryReset(false); }); - }, [sendAPI]); + }; - const handleFactoryResetClose = useCallback(() => { + const handleFactoryResetClose = () => { setConfirmFactoryReset(false); - }, []); + }; - const handleFactoryResetClick = useCallback(() => { + const handleFactoryResetClick = () => { setConfirmFactoryReset(true); - }, []); + }; - const content = useMemo(() => { - return ( - <> - - + if (restarting) { + return ; + } - + return ( + + + - + - + - + - + - + - - + - - {LL.FACTORY_RESET()} - {LL.SYSTEM_FACTORY_TEXT_DIALOG()} - - - - - + + - - - + + {LL.FACTORY_RESET()} + {LL.SYSTEM_FACTORY_TEXT_DIALOG()} + + - - - ); - }, [ - LL, - handleFactoryResetClick, - handleFactoryResetClose, - doFormat, - confirmFactoryReset, - restarting - ]); + + - return restarting ? : {content}; + + + + + + + ); }; export default Settings; diff --git a/interface/src/app/settings/TZ.tsx b/interface/src/app/settings/TZ.tsx index c734f1809..e0ff35294 100644 --- a/interface/src/app/settings/TZ.tsx +++ b/interface/src/app/settings/TZ.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import { MenuItem } from '@mui/material'; export const TIME_ZONES: Record = { @@ -472,26 +470,16 @@ export function selectedTimeZone(label: string, format: string) { return TIME_ZONES[label] === format ? label : undefined; } -// Memoized version for use in components -export function useTimeZoneSelectItems() { - return useMemo( - () => - TIME_ZONE_LABELS.map((label) => ( - - {label} - - )), - [] - ); -} - -// Fallback export for backward compatibility - now memoized const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( {label} )); +export function useTimeZoneSelectItems() { + return precomputedTimeZoneItems; +} + export function timeZoneSelectItems() { return precomputedTimeZoneItems; } diff --git a/interface/src/app/settings/network/Network.tsx b/interface/src/app/settings/network/Network.tsx index 300010e3f..e90e5b835 100644 --- a/interface/src/app/settings/network/Network.tsx +++ b/interface/src/app/settings/network/Network.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useState } from 'react'; import { Navigate, Route, @@ -40,26 +40,20 @@ const Network = () => { const [selectedNetwork, setSelectedNetwork] = useState(); - const selectNetwork = useCallback( - (network: WiFiNetwork) => { - setSelectedNetwork(network); - void navigate('/settings/network/settings'); - }, - [navigate] - ); + const selectNetwork = (network: WiFiNetwork) => { + setSelectedNetwork(network); + void navigate('/settings/network/settings'); + }; - const deselectNetwork = useCallback(() => { + const deselectNetwork = () => { setSelectedNetwork(undefined); - }, []); + }; - const contextValue = useMemo( - () => ({ - ...(selectedNetwork && { selectedNetwork }), - selectNetwork, - deselectNetwork - }), - [selectedNetwork, selectNetwork, deselectNetwork] - ); + const contextValue = { + ...(selectedNetwork && { selectedNetwork }), + selectNetwork, + deselectNetwork + }; return ( diff --git a/interface/src/app/settings/network/NetworkSettings.tsx b/interface/src/app/settings/network/NetworkSettings.tsx index 5f96aa594..89789ccc2 100644 --- a/interface/src/app/settings/network/NetworkSettings.tsx +++ b/interface/src/app/settings/network/NetworkSettings.tsx @@ -121,19 +121,19 @@ const NetworkSettings = () => { deselectNetwork(); }, [data, saveData, deselectNetwork]); - const setCancel = useCallback(async () => { + const setCancel = async () => { deselectNetwork(); await loadData(); - }, [deselectNetwork, loadData]); + }; - const doRestart = useCallback(async () => { + const doRestart = async () => { setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); - }, [sendAPI]); + }; const content = () => { if (!data) { diff --git a/interface/src/app/settings/network/WiFiNetworkScanner.tsx b/interface/src/app/settings/network/WiFiNetworkScanner.tsx index b4517b0f1..b7d58206c 100644 --- a/interface/src/app/settings/network/WiFiNetworkScanner.tsx +++ b/interface/src/app/settings/network/WiFiNetworkScanner.tsx @@ -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 { Button } from '@mui/material'; @@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => { } }); - const renderNetworkScanner = useCallback(() => { + const renderNetworkScanner = () => { if (!networkList) { return ; } return ; - }, [networkList, errorMessage]); + }; return ( diff --git a/interface/src/app/settings/network/WiFiNetworkSelector.tsx b/interface/src/app/settings/network/WiFiNetworkSelector.tsx index bfc0f9949..e7b7d327b 100644 --- a/interface/src/app/settings/network/WiFiNetworkSelector.tsx +++ b/interface/src/app/settings/network/WiFiNetworkSelector.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext } from 'react'; +import { memo, useContext } from 'react'; import LockIcon from '@mui/icons-material/Lock'; import LockOpenIcon from '@mui/icons-material/LockOpen'; @@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList }) const wifiConnectionContext = useContext(WiFiConnectionContext); - const renderNetwork = useCallback( - (network: WiFiNetwork) => ( - wifiConnectionContext.selectNetwork(network)} - > - - {isNetworkOpen(network) ? : } - - - - - - - - - ), - [wifiConnectionContext, theme] + const renderNetwork = (network: WiFiNetwork) => ( + wifiConnectionContext.selectNetwork(network)} + > + + {isNetworkOpen(network) ? : } + + + + + + + + ); if (networkList.networks.length === 0) { diff --git a/interface/src/app/settings/security/ManageUsers.tsx b/interface/src/app/settings/security/ManageUsers.tsx index bc17261d8..9dec88ee9 100644 --- a/interface/src/app/settings/security/ManageUsers.tsx +++ b/interface/src/app/settings/security/ManageUsers.tsx @@ -99,34 +99,28 @@ const ManageUsers = () => { [] ); - const noAdminConfigured = useCallback( - () => !data?.users.find((u) => u.admin), - [data] - ); + const noAdminConfigured = () => !data?.users.find((u) => u.admin); - const removeUser = useCallback( - (toRemove: UserType) => { - if (!data) return; - const users = data.users.filter((u) => u.username !== toRemove.username); - updateDataValue({ ...data, users }); - setChanged(changed + 1); - }, - [data, updateDataValue, changed] - ); + const removeUser = (toRemove: UserType) => { + if (!data) return; + const users = data.users.filter((u) => u.username !== toRemove.username); + updateDataValue({ ...data, users }); + setChanged(changed + 1); + }; - const createUser = useCallback(() => { + const createUser = () => { setCreating(true); setUser({ username: '', password: '', admin: true }); - }, []); + }; - const editUser = useCallback((toEdit: UserType) => { + const editUser = (toEdit: UserType) => { setCreating(false); setUser({ ...toEdit }); - }, []); + }; const cancelEditingUser = useCallback(() => { setUser(undefined); @@ -150,20 +144,20 @@ const ManageUsers = () => { setGeneratingToken(undefined); }, []); - const generateTokenForUser = useCallback((username: string) => { + const generateTokenForUser = (username: string) => { setGeneratingToken(username); - }, []); + }; - const onSubmit = useCallback(async () => { + const onSubmit = async () => { await saveData(); await authenticatedContext.refresh(); setChanged(0); - }, [saveData, authenticatedContext]); + }; - const onCancelSubmit = useCallback(async () => { + const onCancelSubmit = async () => { await loadData(); setChanged(0); - }, [loadData]); + }; const content = () => { if (!data) { @@ -177,15 +171,10 @@ const ManageUsers = () => { admin: boolean; } - // add id to the type, needed for the table - const user_table = useMemo( - () => - data.users.map((u) => ({ - ...u, - id: u.username - })) as UserType2[], - [data.users] - ); + const user_table = data.users.map((u) => ({ + ...u, + id: u.username + })) as UserType2[]; return ( <> diff --git a/interface/src/app/settings/security/Security.tsx b/interface/src/app/settings/security/Security.tsx index 012aac55a..5701a907c 100644 --- a/interface/src/app/settings/security/Security.tsx +++ b/interface/src/app/settings/security/Security.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Tab } from '@mui/material'; @@ -15,19 +15,15 @@ const Security = () => { const location = useLocation(); - const matchedRoutes = useMemo( - () => - matchRoutes( - [ - { - path: '/settings/security/settings', - element: - }, - { path: '/settings/security/users', element: } - ], - location - ), - [location] + const matchedRoutes = matchRoutes( + [ + { + path: '/settings/security/settings', + element: + }, + { path: '/settings/security/users', element: } + ], + location ); const routerTab = matchedRoutes?.[0]?.route.path || false; diff --git a/interface/src/app/settings/security/User.tsx b/interface/src/app/settings/security/User.tsx index bf1ce8862..093ad5d33 100644 --- a/interface/src/app/settings/security/User.tsx +++ b/interface/src/app/settings/security/User.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import type { FC } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -62,7 +62,7 @@ const User: FC = ({ } }, [open]); - const validateAndDone = useCallback(async () => { + const validateAndDone = async () => { if (user) { try { setFieldErrors(undefined); @@ -72,7 +72,7 @@ const User: FC = ({ setFieldErrors((error as ValidationError).fieldErrors); } } - }, [user, validator, onDoneEditing]); + }; return ( { useLayoutTitle(LL.DATA_TRAFFIC()); - const stats_theme = tableTheme( - useMemo( - () => ({ - Table: ` + const stats_theme = tableTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -55,7 +51,7 @@ const SystemActivity = () => { border-bottom: 1px solid #565656; } `, - Row: ` + Row: ` .td { padding: 8px; border-top: 1px solid #565656; @@ -69,26 +65,20 @@ const SystemActivity = () => { background-color: #1e1e1e; } `, - BaseCell: ` + BaseCell: ` &:not(:first-of-type) { text-align: center; } ` - }), - [] - ) - ); + }); - const showName = useCallback( - (id: number) => { - const name: keyof Translation['STATUS_NAMES'] = - id.toString() as keyof Translation['STATUS_NAMES']; - return LL.STATUS_NAMES[name](); - }, - [LL] - ); + const showName = (id: number) => { + const name: keyof Translation['STATUS_NAMES'] = + id.toString() as keyof Translation['STATUS_NAMES']; + return LL.STATUS_NAMES[name](); + }; - const showQuality = useCallback((stat: Stat) => { + const showQuality = (stat: Stat) => { if (stat.q === 0 || stat.s + stat.f === 0) { return; } @@ -100,14 +90,18 @@ const SystemActivity = () => { } else { return
{stat.q}%
; } - }, []); - - const content = useMemo(() => { - if (!data) { - return ; - } + }; + if (!data) { return ( + + + + ); + } + + return ( + { )}
- ); - }, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]); - - return {content}; +
+ ); }; export default SystemActivity; diff --git a/interface/src/app/status/MqttStatus.tsx b/interface/src/app/status/MqttStatus.tsx index 7e38b633b..37b2507e4 100644 --- a/interface/src/app/status/MqttStatus.tsx +++ b/interface/src/app/status/MqttStatus.tsx @@ -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 DeviceHubIcon from '@mui/icons-material/DeviceHub'; @@ -127,16 +127,15 @@ const MqttStatus = () => { void loadData(); }); - // Memoize error message separately to avoid re-renders on error object changes const errorMessage = error?.message || ''; - const mqttStatusText = useMemo(() => { - if (!data) return ''; - if (!data.enabled) return LL.NOT_ENABLED(); - return data.connected - ? `${LL.CONNECTED(0)} (${data.connect_count})` - : `${LL.DISCONNECTED()} (${data.connect_count})`; - }, [data, LL]); + const mqttStatusText = !data + ? '' + : !data.enabled + ? LL.NOT_ENABLED() + : data.connected + ? `${LL.CONNECTED(0)} (${data.connect_count})` + : `${LL.DISCONNECTED()} (${data.connect_count})`; if (!data) { return ( diff --git a/interface/src/app/status/NTPStatus.tsx b/interface/src/app/status/NTPStatus.tsx index 8dc019d67..46c234b7d 100644 --- a/interface/src/app/status/NTPStatus.tsx +++ b/interface/src/app/status/NTPStatus.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import AccessTimeIcon from '@mui/icons-material/AccessTime'; import DnsIcon from '@mui/icons-material/Dns'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; @@ -67,12 +65,16 @@ const NTPStatus = () => { } }; - const content = useMemo(() => { - if (!data) { - return ; - } - + if (!data) { return ( + + + + ); + } + + return ( + @@ -121,10 +123,8 @@ const NTPStatus = () => { - ); - }, [data, error, loadData, LL, theme]); - - return {content}; + + ); }; export default NTPStatus; diff --git a/interface/src/app/status/NetworkStatus.tsx b/interface/src/app/status/NetworkStatus.tsx index 6172c8186..e72dcac95 100644 --- a/interface/src/app/status/NetworkStatus.tsx +++ b/interface/src/app/status/NetworkStatus.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DnsIcon from '@mui/icons-material/Dns'; import GiteIcon from '@mui/icons-material/Gite'; @@ -124,16 +122,20 @@ const NetworkStatus = () => { const theme = useTheme(); - const content = useMemo(() => { - if (!data) { - return ; - } - - const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL); - const statusColor = networkStatusHighlight(data, theme); - const qualityColor = networkQualityHighlight(data, theme); - + if (!data) { return ( + + + + ); + } + + const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL); + const statusColor = networkStatusHighlight(data, theme); + const qualityColor = networkQualityHighlight(data, theme); + + return ( + @@ -227,10 +229,8 @@ const NetworkStatus = () => { )} - ); - }, [data, error, loadData, LL, theme]); - - return {content}; + + ); }; export default NetworkStatus; diff --git a/interface/src/app/status/Status.tsx b/interface/src/app/status/Status.tsx index 1d87a5d4d..e5a08e1f5 100644 --- a/interface/src/app/status/Status.tsx +++ b/interface/src/app/status/Status.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useContext, useState } from 'react'; import { toast } from 'react-toastify'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; @@ -43,7 +43,6 @@ import { formatDateTime } from 'utils/time'; 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 formatDurationSec = ( @@ -97,10 +96,8 @@ const SystemStatus = () => { const theme = useTheme(); - // Memoize derived status values to avoid recalculation on every render - const busStatus = useMemo(() => { + const busStatus = (() => { if (!data) return 'EMS state unknown'; - switch (data.bus_status) { case busConnectionStatus.BUS_STATUS_CONNECTED: return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; @@ -111,12 +108,10 @@ const SystemStatus = () => { default: return 'EMS state unknown'; } - }, [data?.bus_status, data?.bus_uptime, LL]); + })(); - // Memoize derived status values to avoid recalculation on every render - const systemStatus = useMemo(() => { + const systemStatus = (() => { if (!data) return '??'; - switch (data.status) { case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: @@ -129,14 +124,12 @@ const SystemStatus = () => { case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: return LL.GPIO_OF(LL.FAILED(0)); default: - // SystemStatusCodes.SYSTEM_STATUS_NORMAL return 'OK'; } - }, [data?.status, LL]); + })(); - const busStatusHighlight = useMemo(() => { + const busStatusHighlight = (() => { if (!data) return theme.palette.warning.main; - switch (data.bus_status) { case busConnectionStatus.BUS_STATUS_TX_ERRORS: return theme.palette.warning.main; @@ -147,11 +140,10 @@ const SystemStatus = () => { default: return theme.palette.warning.main; } - }, [data?.bus_status, theme.palette]); + })(); - const ntpStatus = useMemo(() => { + const ntpStatus = (() => { if (!data) return LL.UNKNOWN(); - switch (data.ntp_status) { case NTPSyncStatus.NTP_DISABLED: return LL.NOT_ENABLED(); @@ -164,11 +156,10 @@ const SystemStatus = () => { default: return LL.UNKNOWN(); } - }, [data?.ntp_status, data?.ntp_time, LL]); + })(); - const ntpStatusHighlight = useMemo(() => { + const ntpStatusHighlight = (() => { if (!data) return theme.palette.error.main; - switch (data.ntp_status) { case NTPSyncStatus.NTP_DISABLED: return theme.palette.info.main; @@ -179,11 +170,10 @@ const SystemStatus = () => { default: return theme.palette.error.main; } - }, [data?.ntp_status, theme.palette]); + })(); - const networkStatusHighlight = useMemo(() => { + const networkStatusHighlight = (() => { if (!data) return theme.palette.warning.main; - switch (data.network_status) { case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: @@ -198,11 +188,10 @@ const SystemStatus = () => { default: return theme.palette.warning.main; } - }, [data?.network_status, theme.palette]); + })(); - const networkStatus = useMemo(() => { + const networkStatus = (() => { if (!data) return LL.UNKNOWN(); - switch (data.network_status) { case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: return LL.INACTIVE(1); @@ -223,15 +212,12 @@ const SystemStatus = () => { default: return LL.UNKNOWN(); } - }, [data?.network_status, data?.wifi_rssi, LL]); + })(); - const activeHighlight = useCallback( - (value: boolean) => - value ? theme.palette.success.main : theme.palette.info.main, - [theme.palette] - ); + const activeHighlight = (value: boolean) => + value ? theme.palette.success.main : theme.palette.info.main; - const doRestart = useCallback(async () => { + const doRestart = async () => { setConfirmRestart(false); setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( @@ -239,14 +225,123 @@ const SystemStatus = () => { toast.error(error.message); } ); - }, [sendAPI]); + }; - const handleCloseRestartDialog = useCallback(() => { - setConfirmRestart(false); - }, []); + const handleCloseRestartDialog = () => setConfirmRestart(false); + + if (restarting) { + return ; + } + + if (!data || !LL) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + {me.admin && ( + + )} + + + + + + + + + + + + + + + + - const renderRestartDialog = useMemo( - () => ( { - ), - [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 ; - } - - return ( - <> - - - - - - - - - - - {me.admin && ( - - )} - - - - - - - - - - - - - - - - - - {renderRestartDialog} - - ); - }, [ - data, - LL, - firmwareVersion, - uptimeText, - freeMemoryText, - networkIcon, - mqttStatusText, - apStatusText, - busStatus, - busStatusHighlight, - networkStatusHighlight, - networkStatus, - ntpStatusHighlight, - ntpStatus, - activeHighlight, - me.admin, - handleRestartClick, - error, - loadData, - renderRestartDialog - ]); - - return restarting ? : {content}; }; export default SystemStatus; diff --git a/interface/src/app/status/SystemLog.tsx b/interface/src/app/status/SystemLog.tsx index 628e7a2a1..76d384135 100644 --- a/interface/src/app/status/SystemLog.tsx +++ b/interface/src/app/status/SystemLog.tsx @@ -1,11 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState -} from 'react'; +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -185,8 +178,7 @@ const SystemLog = () => { }; }, [data]); // Recalculate when data changes (in case layout shifts) - // Memoize message handler to avoid recreating on every render - const handleLogMessage = useCallback((message: { data: string }) => { + const handleLogMessage = (message: { data: string }) => { const rawData = message.data; const logentry = JSON.parse(rawData) as LogEntry; setLogEntries((log) => { @@ -200,7 +192,7 @@ const SystemLog = () => { const newLog = [...log, logentry]; return newLog; }); - }, []); + }; useSSE(fetchLogES, { immediate: true, @@ -211,7 +203,7 @@ const SystemLog = () => { toast.error('No connection to Log service'); }); - const onDownload = useCallback(() => { + const onDownload = () => { const result = logEntries .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .join('\n'); @@ -225,11 +217,11 @@ const SystemLog = () => { document.body.appendChild(a); a.click(); document.body.removeChild(a); - }, [logEntries]); + }; - const saveSettings = useCallback(async () => { + const saveSettings = async () => { await saveData(); - }, [saveData]); + }; // handle scrolling - optimized to only scroll when needed const ref = useRef(null); @@ -246,7 +238,7 @@ const SystemLog = () => { } }, [logEntries.length, autoscroll]); - const sendReadCommand = useCallback(() => { + const sendReadCommand = () => { if (readValue === '') { setReadOpen(!readOpen); return; @@ -257,7 +249,7 @@ const SystemLog = () => { setReadOpen(false); setReadValue(''); } - }, [readValue, readOpen, send]); + }; const content = () => { if (!data) { diff --git a/interface/src/app/status/SystemMonitor.tsx b/interface/src/app/status/SystemMonitor.tsx index 4f4423be7..b6fa12d1f 100644 --- a/interface/src/app/status/SystemMonitor.tsx +++ b/interface/src/app/status/SystemMonitor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import { Box, Button, Typography } from '@mui/material'; @@ -57,39 +57,31 @@ const SystemMonitor = () => { void send(); }, 1000); // check every 1 second - const { statusMessage, isUploading, progressValue } = useMemo(() => { - const status = data?.status; + const status = data?.status; - const message = - status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING - ? LL.WAIT_FIRMWARE() - : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART - ? LL.APPLICATION_RESTARTING() - : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL - ? LL.RESTARTING_PRE() - : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD - ? 'Upload Failed' - : LL.RESTARTING_POST(); + const statusMessage = + status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING + ? LL.WAIT_FIRMWARE() + : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART + ? LL.APPLICATION_RESTARTING() + : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL + ? LL.RESTARTING_PRE() + : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD + ? 'Upload Failed' + : LL.RESTARTING_POST(); - const uploading = - status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; - const progress = - uploading && status - ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) - : 0; + const isUploading = + status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; + const progressValue = + isUploading && status + ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) + : 0; - return { - statusMessage: message, - isUploading: uploading, - progressValue: progress - }; - }, [data?.status, LL]); - - const onCancel = useCallback(async () => { + const onCancel = async () => { setErrorMessage(undefined); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); document.location.href = '/'; - }, [setSystemStatus]); + }; return ( )} - {version.date && ( + {version && version.date && ( void; onInstall: (url: string) => void; }) => { - const binURL = useMemo(() => { + const binURL = (() => { if (!latestVersion || !latestDevVersion) return ''; - const version = fetchDevVersion ? latestDevVersion : latestVersion; const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`; - return fetchDevVersion ? `${DEV_URL}${filename}` : `${STABLE_URL}v${version.version}/${filename}`; - }, [fetchDevVersion, latestVersion, latestDevVersion, platform]); + })(); return ( @@ -532,396 +530,340 @@ const Version = () => { toast.error(String(error.error?.message || 'An error occurred')); }); - const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]); + const platform = data ? getPlatform(data) : ''; - const otherPartitions = useMemo( - () => data?.partitions.filter((p) => p.partition !== data.partition) ?? [], - [data] - ); + const otherPartitions = + data?.partitions.filter((p) => p.partition !== data.partition) ?? []; - const setPartitionVersionInfo = useCallback( - (partition: string) => { - setShowVersionInfo(3); + const setPartitionVersionInfo = (partition: string) => { + setShowVersionInfo(3); + const partitionData = data?.partitions.find((p) => p.partition === partition); + if (partitionData) { + setPartitionVersion({ + version: partitionData.version, + date: partitionData.install_date ?? '' + }); + setPartition(partitionData.partition); + setFirmwareSize(partitionData.size); + } + }; - // search for the partition in the data.partitions array - const partitionData = data?.partitions.find((p) => p.partition === partition); - if (partitionData) { - setPartitionVersion({ - version: partitionData.version, - date: partitionData.install_date ?? '' - }); - setPartition(partitionData.partition); - setFirmwareSize(partitionData.size); - } - }, - [data] - ); - - const doRestart = useCallback(async () => { + const doRestart = async () => { await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); setRestarting(true); - }, [sendAPI]); + }; - const installFirmwareURL = useCallback( - async (url: string) => { - await sendUploadURL(url).catch((error: Error) => { - toast.error(error.message); - }); - await doRestart(); - }, - [sendUploadURL, doRestart] - ); + const installFirmwareURL = async (url: string) => { + await sendUploadURL(url).catch((error: Error) => { + toast.error(error.message); + }); + await doRestart(); + }; - const installPartitionFirmware = useCallback( - async (partition: string) => { - await sendSetPartition(partition).catch((error: Error) => { - toast.error(error.message); - }); - setRestarting(true); - }, - [sendSetPartition] - ); + const installPartitionFirmware = async (partition: string) => { + await sendSetPartition(partition).catch((error: Error) => { + toast.error(error.message); + }); + setRestarting(true); + }; - const showPartitionDialog = useCallback( - (version: string, partition: string, install_date: string) => { - setOpenInstallPartitionDialog(true); - setPartitionVersion({ version: version, date: install_date }); - setPartition(partition); - }, - [] - ); + const showPartitionDialog = ( + version: string, + partition: string, + install_date: string + ) => { + setOpenInstallPartitionDialog(true); + setPartitionVersion({ version: version, date: install_date }); + setPartition(partition); + }; - const showFirmwareDialog = useCallback( - (useDevVersion: boolean) => { - setFetchDevVersion(useDevVersion); - void checkUpgradeImportantMessages( - useDevVersion ? latestDevVersion?.version : latestVersion?.version - ); - setOpenInstallDialog(true); - }, - [latestDevVersion, latestVersion, fetchDevVersion] - ); + const showFirmwareDialog = (useDevVersion: boolean) => { + setFetchDevVersion(useDevVersion); + const targetVersion = useDevVersion + ? latestDevVersion?.version + : latestVersion?.version; + if (targetVersion) { + void checkUpgradeImportantMessages(targetVersion); + } + setOpenInstallDialog(true); + }; - const closeInstallDialog = useCallback(() => { - setOpenInstallDialog(false); - }, []); + const closeInstallDialog = () => setOpenInstallDialog(false); + const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false); - const closeInstallPartitionDialog = useCallback(() => { - setOpenInstallPartitionDialog(false); - }, []); - - const handleVersionInfoClose = useCallback(() => { + const handleVersionInfoClose = () => { setShowVersionInfo(0); setPartitionVersion(undefined); setPartition(''); - }, []); + }; useLayoutTitle('EMS-ESP Firmware'); - const showButtons = useCallback( - (showingDev: boolean) => { - const choice = showingDev - ? !usingDevVersion - ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) - : devUpgradeAvailable - ? LL.UPDATE_AVAILABLE() - : undefined - : usingDevVersion - ? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) - : stableUpgradeAvailable - ? LL.UPDATE_AVAILABLE() - : undefined; - - if (!choice) { - return ( - <> - - - {LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} - - - - ); - } - - if (!me.admin) return null; + const showButtons = (showingDev: boolean) => { + const choice = showingDev + ? !usingDevVersion + ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) + : devUpgradeAvailable + ? LL.UPDATE_AVAILABLE() + : undefined + : usingDevVersion + ? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) + : stableUpgradeAvailable + ? LL.UPDATE_AVAILABLE() + : undefined; + if (!choice) { return ( - + <> + + + {LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} + + + ); - }, - [ - usingDevVersion, - devUpgradeAvailable, - stableUpgradeAvailable, - me.admin, - LL, - showFirmwareDialog - ] - ); - - const content = useMemo(() => { - if (!data) { - return ; } + if (!me.admin) return null; + return ( - <> - - - {LL.THIS_VERSION()} - + + ); + }; - - - {LL.VERSION()} - - - - {data.emsesp_version} - {data.build_flags && ( - -   ({data.build_flags}) - - )} - setPartitionVersionInfo(data.partition)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - - + if (restarting) { + return ; + } - - {LL.PLATFORM()} - - - - {platform} + if (!data) { + return ( + + + + ); + } + + return ( + + + + {LL.THIS_VERSION()} + + + + + {LL.VERSION()} + + + + {data.emsesp_version} + {data.build_flags && ( -   ( - {data.psram ? ( - - ) : ( - - )} - PSRAM) +   ({data.build_flags}) - - + )} + setPartitionVersionInfo(data.partition)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + - {internetLive ? ( - <> - - {LL.AVAILABLE_VERSION()} - - - - {otherPartitions.length > 0 && data.developer_mode && ( - <> - - - {LL.STORED_VERSIONS()} - - - - {otherPartitions.map((partition) => ( - - {partition.version} - - setPartitionVersionInfo(partition.partition) - } - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - - - ))} - - + + {LL.PLATFORM()} + + + + {platform} + +   ( + {data.psram ? ( + + ) : ( + )} - - {LL.STABLE()} - - - - {latestVersion?.version} - setShowVersionInfo(1)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(false)} - - - - - {LL.DEVELOPMENT()} - - - - {latestDevVersion?.version} - setShowVersionInfo(2)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(true)} - - - - - ) : ( - - - {LL.INTERNET_CONNECTION_REQUIRED()} - - )} - {me.admin && ( - <> - - - - - {LL.UPLOAD()} + PSRAM) - - - )} - - - ); - }, [ - 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 ? : {content}; + {internetLive ? ( + <> + + {LL.AVAILABLE_VERSION()} + + + + {otherPartitions.length > 0 && data.developer_mode && ( + <> + + {LL.STORED_VERSIONS()} + + + {otherPartitions.map((partition) => ( + + {partition.version} + + setPartitionVersionInfo(partition.partition) + } + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + + + ))} + + + )} + + {LL.STABLE()} + + + + {latestVersion?.version} + setShowVersionInfo(1)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + {showButtons(false)} + + + + + {LL.DEVELOPMENT()} + + + + {latestDevVersion?.version} + setShowVersionInfo(2)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + {showButtons(true)} + + + + + ) : ( + + + {LL.INTERNET_CONNECTION_REQUIRED()} + + )} + {me.admin && ( + <> + + + + + {LL.UPLOAD()} + + + + )} + +
+ ); }; export default memo(Version); diff --git a/interface/src/components/MessageBox.tsx b/interface/src/components/MessageBox.tsx index aa17e6215..ae7d8e177 100644 --- a/interface/src/components/MessageBox.tsx +++ b/interface/src/components/MessageBox.tsx @@ -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 ErrorIcon from '@mui/icons-material/Error'; @@ -38,18 +38,17 @@ const MessageBox: FC> = ({ }) => { const theme = useTheme(); - const { Icon, backgroundColor } = useMemo(() => { - const Icon = LEVEL_ICONS[level]; - const palettePath = LEVEL_PALETTE_PATHS[level]; - const [key, shade] = palettePath.split('.') as [ - keyof typeof theme.palette, - string - ]; - const paletteKey = theme.palette[key] as unknown as Record; - const backgroundColor = paletteKey[shade]; - - return { Icon, backgroundColor }; - }, [level, theme]); + const Icon = LEVEL_ICONS[level]; + const palettePath = LEVEL_PALETTE_PATHS[level]; + const [paletteKeyName, shade] = palettePath.split('.') as [ + keyof typeof theme.palette, + string + ]; + const paletteKey = theme.palette[paletteKeyName] as unknown as Record< + string, + string + >; + const backgroundColor = paletteKey[shade]; return ( { const { setLocale, locale, LL } = useContext(I18nContext); - const onLocaleSelected: ChangeEventHandler = useCallback( - async ({ target }) => { - const loc = target.value as Locales; - localStorage.setItem('lang', loc); - await loadLocaleAsync(loc); - setLocale(loc); - }, - [setLocale] - ); - - // Memoize menu items to prevent recreation on every render - const menuItems = useMemo( - () => - LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( - - {label} -  {label} - - )), - [] - ); + const onLocaleSelected: ChangeEventHandler = async ({ + target + }) => { + const loc = target.value as Locales; + localStorage.setItem('lang', loc); + await loadLocaleAsync(loc); + setLocale(loc); + }; return ( { size="small" select > - {menuItems} + {LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( + + {label} +  {label} + + ))} ); }; diff --git a/interface/src/components/inputs/ValidatedPasswordField.tsx b/interface/src/components/inputs/ValidatedPasswordField.tsx index 44ab69995..5529e7d72 100644 --- a/interface/src/components/inputs/ValidatedPasswordField.tsx +++ b/interface/src/components/inputs/ValidatedPasswordField.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react'; +import { memo, useState } from 'react'; import type { FC } from 'react'; import VisibilityIcon from '@mui/icons-material/Visibility'; @@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit; const ValidatedPasswordField: FC = ({ ...props }) => { const [showPassword, setShowPassword] = useState(false); - const togglePasswordVisibility = useCallback(() => { + const togglePasswordVisibility = () => { setShowPassword((prev) => !prev); - }, []); + }; return ( = ({ children }) => { const [title, setTitle] = useState(PROJECT_NAME); const { pathname } = useLocation(); - // Memoize drawer toggle handler to prevent unnecessary re-renders const handleDrawerToggle = useCallback(() => { setMobileOpen((prev) => !prev); }, []); @@ -28,7 +27,6 @@ const LayoutComponent: FC = ({ children }) => { setMobileOpen(false); }, [pathname]); - // Memoize context value to prevent unnecessary re-renders const contextValue = useMemo(() => ({ title, setTitle }), [title]); return ( diff --git a/interface/src/components/layout/LayoutAppBar.tsx b/interface/src/components/layout/LayoutAppBar.tsx index 6bea11462..9ee84303a 100644 --- a/interface/src/components/layout/LayoutAppBar.tsx +++ b/interface/src/components/layout/LayoutAppBar.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; @@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => const navigate = useNavigate(); const location = useLocation(); - const pathnames = useMemo( - () => location.pathname.split('/').filter((x) => x), - [location.pathname] - ); + const pathnames = location.pathname.split('/').filter((x) => x); - const handleBackClick = useCallback(() => { + const handleBackClick = () => { void navigate('/' + pathnames[0]); - }, [navigate, pathnames]); + }; return ( diff --git a/interface/src/components/layout/LayoutDrawer.tsx b/interface/src/components/layout/LayoutDrawer.tsx index c89fb396b..89237615f 100644 --- a/interface/src/components/layout/LayoutDrawer.tsx +++ b/interface/src/components/layout/LayoutDrawer.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; @@ -24,22 +24,18 @@ interface LayoutDrawerProps { } const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { - // Memoize drawer content to prevent unnecessary re-renders - const drawer = useMemo( - () => ( - <> - - - - {PROJECT_NAME} - - - - - - - ), - [] + const drawer = ( + <> + + + + {PROJECT_NAME} + + + + + + ); return ( diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx index 1fa3830b5..48ce3e626 100644 --- a/interface/src/components/layout/LayoutMenu.tsx +++ b/interface/src/components/layout/LayoutMenu.tsx @@ -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 AssessmentIcon from '@mui/icons-material/Assessment'; @@ -22,9 +22,9 @@ const LayoutMenuComponent = () => { const { LL } = useI18nContext(); const [menuOpen, setMenuOpen] = useState(true); - const handleMenuToggle = useCallback(() => { + const handleMenuToggle = () => { setMenuOpen((prev) => !prev); - }, []); + }; return ( <> diff --git a/interface/src/components/layout/LayoutMenuItem.tsx b/interface/src/components/layout/LayoutMenuItem.tsx index a0dbc8354..2ec78433f 100644 --- a/interface/src/components/layout/LayoutMenuItem.tsx +++ b/interface/src/components/layout/LayoutMenuItem.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Link, useLocation } from 'react-router'; import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; @@ -21,50 +21,40 @@ const LayoutMenuItemComponent = ({ }: LayoutMenuItemProps) => { 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 = useMemo( - () => ({ - transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', - backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', - borderRadius: '8px', - margin: '2px 8px', - '&:hover': { - backgroundColor: 'rgba(68, 82, 211, 0.39)' - }, - '&::before': { - content: '""', - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: selected ? '3px' : '0px', - backgroundColor: '#90caf9', - transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' - } - }), - [selected] - ); + const buttonStyles: SxProps = { + transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', + borderRadius: '8px', + margin: '2px 8px', + '&:hover': { + backgroundColor: 'rgba(68, 82, 211, 0.39)' + }, + '&::before': { + content: '""', + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: selected ? '3px' : '0px', + backgroundColor: '#90caf9', + transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' + } + }; - const iconStyles: SxProps = useMemo( - () => ({ - color: selected ? '#90caf9' : '#9e9e9e', - transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', - transform: selected ? 'scale(1.1)' : 'scale(1)', - transitionProperty: 'color, transform' - }), - [selected] - ); + const iconStyles: SxProps = { + color: selected ? '#90caf9' : '#9e9e9e', + transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transform: selected ? 'scale(1.1)' : 'scale(1)', + transitionProperty: 'color, transform' + }; - const textStyles: SxProps = useMemo( - () => ({ - color: selected ? '#90caf9' : '#f5f5f5', - transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', - transitionProperty: 'color, font-weight' - }), - [selected] - ); + const textStyles: SxProps = { + color: selected ? '#90caf9' : '#f5f5f5', + transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transitionProperty: 'color, font-weight' + }; return ( { const { LL } = useI18nContext(); - const handleReset = useCallback(() => { + const handleReset = () => { blocker.reset?.(); - }, [blocker]); + }; - const handleProceed = useCallback(() => { + const handleProceed = () => { blocker.proceed?.(); - }, [blocker]); + }; return ( diff --git a/interface/src/components/routing/RouterTabs.tsx b/interface/src/components/routing/RouterTabs.tsx index 9a3d7d31e..f8c6b2a09 100644 --- a/interface/src/components/routing/RouterTabs.tsx +++ b/interface/src/components/routing/RouterTabs.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import type { FC } from 'react'; import { useNavigate } from 'react-router'; @@ -16,12 +16,9 @@ const RouterTabs: FC = ({ value, children }) => { const theme = useTheme(); const smallDown = useMediaQuery(theme.breakpoints.down('sm')); - const handleTabChange = useCallback( - (_event: unknown, path: string) => { - void navigate(path); - }, - [navigate] - ); + const handleTabChange = (_event: unknown, path: string) => { + void navigate(path); + }; return ( = ({ children }) => { void refresh(); }, [refresh]); - // cache object to prevent re-renders const obj = useMemo( () => ({ signIn, diff --git a/interface/src/utils/usePersistState.ts b/interface/src/utils/usePersistState.ts index 2a627a852..43b80afc8 100644 --- a/interface/src/utils/usePersistState.ts +++ b/interface/src/utils/usePersistState.ts @@ -1,34 +1,27 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; export const usePersistState = ( initial_value: T, id: string ): [T, (new_state: T) => void] => { - // Set initial value - only computed once on mount - const _initial_value = useMemo(() => { + const [state, setState] = useState(() => { try { - const local_storage_value_str = localStorage.getItem(`state:${id}`); - // If there is a value stored in localStorage, use that - if (local_storage_value_str) { - return JSON.parse(local_storage_value_str) as T; + const stored = localStorage.getItem(`state:${id}`); + if (stored) { + return JSON.parse(stored) as T; } } catch (error) { - // If parsing fails, fall back to initial_value console.warn( `Failed to parse localStorage value for key "state:${id}"`, error ); } - // Otherwise use initial_value that was passed to the function return initial_value; - }, [id]); // initial_value intentionally omitted - only read on first mount - - const [state, setState] = useState(_initial_value); + }); useEffect(() => { try { - const state_str = JSON.stringify(state); - localStorage.setItem(`state:${id}`, state_str); + localStorage.setItem(`state:${id}`, JSON.stringify(state)); } catch (error) { console.warn( `Failed to save state to localStorage for key "state:${id}"`,