diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 6eb7640453..7727b92201 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -41,7 +41,6 @@ import Heading from "@/components/ui/heading"; import get from "lodash/get"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; -import merge from "lodash/merge"; import { Collapsible, CollapsibleContent, @@ -73,6 +72,7 @@ import { buildConfigDataForPath, flattenOverrides, getBaseCameraSectionValue, + mergeProfileOverrides, resolveHiddenFieldEntries, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, @@ -353,7 +353,10 @@ export function ConfigSection({ `profiles.${profileName}.${sectionPath}`, ); if (profileOverrides && typeof profileOverrides === "object") { - return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides)); + return mergeProfileOverrides( + (baseValue as object) ?? {}, + profileOverrides as object, + ); } return baseValue; } diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index ee33e59b7b..54de9d9ec9 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -6,7 +6,6 @@ import get from "lodash/get"; import cloneDeep from "lodash/cloneDeep"; -import merge from "lodash/merge"; import unset from "lodash/unset"; import isEqual from "lodash/isEqual"; import mergeWith from "lodash/mergeWith"; @@ -92,6 +91,32 @@ export function getBaseCameraSectionValue( return base !== undefined ? base : get(cam, sectionPath); } +// mergeWith customizer that replaces arrays wholesale instead of merging them +// positionally by index. Used when the source value is meant to fully replace +// the destination (e.g. profile overrides, section config overrides), so an +// empty source array correctly clears the destination array. +const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => { + if (Array.isArray(objValue) || Array.isArray(srcValue)) { + return srcValue !== undefined ? srcValue : objValue; + } + return undefined; +}; + +// Merge profile overrides on top of base config values. Matches the backend's +// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by +// the profile's value rather than merged positionally, so an empty array in a +// profile clears the base array instead of leaving stale entries behind. +export function mergeProfileOverrides( + baseValue: T, + profileOverrides: object, +): T { + return mergeWith( + cloneDeep(baseValue), + cloneDeep(profileOverrides), + replaceArraysCustomizer, + ) as T; +} + /** Sections that can appear inside a camera profile definition. */ export const PROFILE_ELIGIBLE_SECTIONS = new Set([ "audio", @@ -564,9 +589,9 @@ export function prepareSectionSavePayload(opts: { baseValue && typeof baseValue === "object" ) { - rawSectionValue = merge( - cloneDeep(baseValue), - cloneDeep(profileOverrides), + rawSectionValue = mergeProfileOverrides( + baseValue as object, + profileOverrides as object, ); } else { rawSectionValue = baseValue; @@ -675,13 +700,12 @@ const mergeSectionConfig = ( overrides: Partial | undefined, ): SectionConfig => mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => { - if (Array.isArray(objValue) || Array.isArray(srcValue)) { - return srcValue ?? objValue; - } + const arrayResult = replaceArraysCustomizer(objValue, srcValue); + if (arrayResult !== undefined) return arrayResult; if (key === "uiSchema") { if (objValue && srcValue) { - return merge({}, objValue, srcValue); + return mergeWith({}, objValue, srcValue, replaceArraysCustomizer); } return srcValue ?? objValue; }