fix profile array overrides not replacing base arrays

don't use lodash merge(), it does positional merging and an empty source array doesn't override the destination, and shorter arrays leak destination elements through.

backend is unaffected, so the saved config and actual backend functionality was right
This commit is contained in:
Josh Hawkins 2026-05-12 15:40:13 -05:00
parent ba9a30e5c9
commit c6b355bd7c
2 changed files with 37 additions and 10 deletions

View File

@ -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;
}

View File

@ -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<T extends object>(
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<SectionConfig> | 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;
}