From 87043f35cbe3d26c3183074e3215c8501841ea43 Mon Sep 17 00:00:00 2001 From: fullburnen <70560944+fullburnen@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:34:45 -0500 Subject: [PATCH] fix(settings): fix unsaved change detection for non-admin-message config items (#1062) Add getRadioConfigChangeCount() and getDeviceConfigChangeCount() for individual setting sections. This resolves displaying the count of unsaved changes on the wrong sidebar item. Add normalizeBytes and call it from the Security page so that unset Admin Keys do not trigger unsaved changes when updating the page. The Position page uses useMemo which does not clear rhfState.isDirty. Remove the rhfState.isDirty from hasPending to address it. --- .../Settings/Security/Security.tsx | 17 ++++++- .../core/stores/deviceStore/changeRegistry.ts | 51 +++++++++++++++++-- .../stores/deviceStore/deviceStore.mock.ts | 2 + .../web/src/core/stores/deviceStore/index.ts | 22 ++++++++ .../web/src/core/utils/deepCompareConfig.ts | 7 +++ packages/web/src/pages/Settings/index.tsx | 16 +++--- 6 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/PageComponents/Settings/Security/Security.tsx b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx index b5fdf3f1..873941a6 100644 --- a/packages/web/src/components/PageComponents/Settings/Security/Security.tsx +++ b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx @@ -9,7 +9,7 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx" import { createZodResolver } from "@components/Form/createZodResolver.ts"; import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; -import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; +import { deepCompareConfig, normalizeBytes } from "@core/utils/deepCompareConfig.ts"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; import { fromByteArray, toByteArray } from "base64-js"; import { useEffect, useState } from "react"; @@ -87,7 +87,20 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { ], }; - if (deepCompareConfig(config.security, payload, true)) { + // Normalize empty byte arrays -> undefined for comparison so + // empty base64 strings from the form match undefined/empty fields + // in the existing config and allow removeChange to work. + const normalizeSecurity = (s: ParsedSecurity | undefined) => { + if (!s) return s; + return { + ...s, + privateKey: normalizeBytes(s.privateKey), + publicKey: normalizeBytes(s.publicKey), + adminKey: s.adminKey?.map((b) => normalizeBytes(b)) as unknown, + } as ParsedSecurity; + }; + + if (deepCompareConfig(normalizeSecurity(config.security), normalizeSecurity(payload), true)) { removeChange({ type: "config", variant: "security" }); return; } diff --git a/packages/web/src/core/stores/deviceStore/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts index cdfcc0b6..2af79bb3 100644 --- a/packages/web/src/core/stores/deviceStore/changeRegistry.ts +++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts @@ -1,15 +1,17 @@ import type { Types } from "@meshtastic/core"; // Config type discriminators -export type ValidConfigType = +export type ValidRadioConfigType = "lora" | "security"; + +export type ValidDeviceConfigType = | "device" | "position" | "power" | "network" | "display" - | "lora" - | "bluetooth" - | "security"; + | "bluetooth"; + +export type ValidConfigType = ValidRadioConfigType | ValidDeviceConfigType; export type ValidModuleConfigType = | "mqtt" @@ -157,6 +159,47 @@ export function getConfigChangeCount(registry: ChangeRegistry): number { return count; } +/** + * Get count of radio config changes + */ +export function getRadioConfigChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if (key.type === "config" && (key.variant === "lora" || key.variant === "security")) { + count++; + } + } + // Channel is displayed under Radio section in UI, so include channel changes in the count + count += getChannelChangeCount(registry); + return count; +} + +/** + * Get count of device config changes + */ +export function getDeviceConfigChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if ( + key.type === "config" && + (key.variant === "device" || + key.variant === "position" || + key.variant === "power" || + key.variant === "network" || + key.variant === "display" || + key.variant === "bluetooth") + ) { + count++; + } + } + if (hasUserChange(registry)) { + count++; + } + return count; +} + /** * Get count of module config changes */ diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index eb67f7bf..e36deafe 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -91,6 +91,8 @@ export const mockDeviceStore: Device = { hasChannelChange: vi.fn().mockReturnValue(false), hasUserChange: vi.fn().mockReturnValue(false), getConfigChangeCount: vi.fn().mockReturnValue(0), + getRadioConfigChangeCount: vi.fn().mockReturnValue(0), + getDeviceConfigChangeCount: vi.fn().mockReturnValue(0), getModuleConfigChangeCount: vi.fn().mockReturnValue(0), getChannelChangeCount: vi.fn().mockReturnValue(0), getAllConfigChanges: vi.fn().mockReturnValue([]), diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index a226d7af..d8f52c93 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -15,6 +15,8 @@ import { getAllModuleConfigChanges, getChannelChangeCount, getConfigChangeCount, + getRadioConfigChangeCount, + getDeviceConfigChangeCount, getModuleConfigChangeCount, hasChannelChange, hasConfigChange, @@ -119,6 +121,8 @@ export interface Device extends DeviceData { hasChannelChange: (index: Types.ChannelNumber) => boolean; hasUserChange: () => boolean; getConfigChangeCount: () => number; + getRadioConfigChangeCount: () => number; + getDeviceConfigChangeCount: () => number; getModuleConfigChangeCount: () => number; getChannelChangeCount: () => number; getAllConfigChanges: () => Protobuf.Config.Config[]; @@ -817,6 +821,24 @@ function deviceFactory( return getConfigChangeCount(device.changeRegistry); }, + getRadioConfigChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getRadioConfigChangeCount(device.changeRegistry); + }, + + getDeviceConfigChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getDeviceConfigChangeCount(device.changeRegistry); + }, + getModuleConfigChangeCount: () => { const device = get().devices.get(id); if (!device) { diff --git a/packages/web/src/core/utils/deepCompareConfig.ts b/packages/web/src/core/utils/deepCompareConfig.ts index cc9bcdb8..5e12c59d 100644 --- a/packages/web/src/core/utils/deepCompareConfig.ts +++ b/packages/web/src/core/utils/deepCompareConfig.ts @@ -6,6 +6,13 @@ function isUint8Array(v: unknown): v is Uint8Array { return v instanceof Uint8Array; } +export function normalizeBytes(value: unknown): unknown { + if (value instanceof Uint8Array && value.byteLength === 0) { + return undefined; + } + return value; +} + function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) { return false; diff --git a/packages/web/src/pages/Settings/index.tsx b/packages/web/src/pages/Settings/index.tsx index 2df577ed..1becf159 100644 --- a/packages/web/src/pages/Settings/index.tsx +++ b/packages/web/src/pages/Settings/index.tsx @@ -36,6 +36,8 @@ const ConfigPage = () => { setModuleConfig, addChannel, getConfigChangeCount, + getRadioConfigChangeCount, + getDeviceConfigChangeCount, getModuleConfigChangeCount, getChannelChangeCount, getAdminMessageChangeCount, @@ -50,9 +52,9 @@ const ConfigPage = () => { const routerState = useRouterState(); const { t } = useTranslation("config"); - const configChangeCount = getConfigChangeCount(); + const radioConfigChangeCount = getRadioConfigChangeCount(); + const deviceConfigChangeCount = getDeviceConfigChangeCount(); const moduleConfigChangeCount = getModuleConfigChangeCount(); - const channelChangeCount = getChannelChangeCount(); const adminMessageChangeCount = getAdminMessageChangeCount(); const sections = useMemo( @@ -62,7 +64,7 @@ const ConfigPage = () => { route: radioRoute, label: t("navigation.radioConfig"), icon: RadioTowerIcon, - changeCount: configChangeCount, + changeCount: radioConfigChangeCount, component: RadioConfig, }, { @@ -70,7 +72,7 @@ const ConfigPage = () => { route: deviceRoute, label: t("navigation.deviceConfig"), icon: RouterIcon, - changeCount: moduleConfigChangeCount, + changeCount: deviceConfigChangeCount, component: DeviceConfig, }, { @@ -78,11 +80,11 @@ const ConfigPage = () => { route: moduleRoute, label: t("navigation.moduleConfig"), icon: LayersIcon, - changeCount: channelChangeCount, + changeCount: moduleConfigChangeCount, component: ModuleConfig, }, ], - [t, configChangeCount, moduleConfigChangeCount, channelChangeCount], + [t, radioConfigChangeCount, deviceConfigChangeCount, moduleConfigChangeCount], ); const activeSection = @@ -263,7 +265,7 @@ const ConfigPage = () => { getModuleConfigChangeCount() > 0 || getChannelChangeCount() > 0 || adminMessageChangeCount > 0; - const hasPending = hasDrafts || rhfState.isDirty; + const hasPending = hasDrafts; const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; const saveDisabled = isSaving || !rhfState.isValid || !hasPending;