From c90a547ec9a3643362709ef037331a95c01f44cd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:12:46 -0600 Subject: [PATCH] preserve form data when changing cameras --- .../config-form/sections/BaseSection.tsx | 59 +++++++++++-------- .../theme/templates/ObjectFieldTemplate.tsx | 14 ++++- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index f0e7716ae..14c861dfe 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -178,8 +178,7 @@ export function ConfigSection({ const [dirtyOverrides, setDirtyOverrides] = useState( undefined, ); - const [baselineFormData, setBaselineFormData] = - useState(null); + const baselineByKeyRef = useRef>({}); const pendingData = pendingDataBySection !== undefined @@ -202,6 +201,7 @@ export function ConfigSection({ const [restartDialogOpen, setRestartDialogOpen] = useState(false); const isResettingRef = useRef(false); const isInitializingRef = useRef(true); + const lastPendingDataKeyRef = useRef(null); const updateTopic = level === "camera" && cameraName @@ -268,6 +268,23 @@ export function ConfigSection({ return sanitizeSectionData(baseData); }, [rawFormData, modifiedSchema, sanitizeSectionData]); + const baselineSnapshot = useMemo(() => { + if (!pendingData) { + const snapshot = cloneDeep(formData as ConfigSectionData); + baselineByKeyRef.current[pendingDataKey] = snapshot; + return snapshot; + } + + const cached = baselineByKeyRef.current[pendingDataKey]; + if (cached) { + return cached; + } + + const snapshot = cloneDeep(formData as ConfigSectionData); + baselineByKeyRef.current[pendingDataKey] = snapshot; + return snapshot; + }, [formData, pendingData, pendingDataKey]); + const schemaDefaults = useMemo(() => { if (!modifiedSchema) { return {}; @@ -297,21 +314,29 @@ export function ConfigSection({ // This prevents RJSF's initial onChange call from being treated as a user edit // Only clear if pendingData is managed locally (not by parent) useEffect(() => { - if (!pendingData) { + const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey; + + if (pendingKeyChanged) { + lastPendingDataKeyRef.current = pendingDataKey; + isInitializingRef.current = true; + setPendingOverrides(undefined); + setDirtyOverrides(undefined); + } else if (!pendingData) { isInitializingRef.current = true; setPendingOverrides(undefined); setDirtyOverrides(undefined); - setBaselineFormData(cloneDeep(formData as ConfigSectionData)); } + if (onPendingDataChange === undefined) { setPendingData(null); } }, [ - formData, - pendingData, - setPendingData, - setBaselineFormData, onPendingDataChange, + pendingData, + pendingDataKey, + setPendingData, + setDirtyOverrides, + setPendingOverrides, ]); useEffect(() => { @@ -344,7 +369,7 @@ export function ConfigSection({ return; } const sanitizedData = sanitizeSectionData(data as ConfigSectionData); - let nextBaselineFormData = baselineFormData ?? formData; + const nextBaselineFormData = baselineSnapshot; const overrides = buildOverrides( sanitizedData, compareBaseData, @@ -353,16 +378,6 @@ export function ConfigSection({ setPendingOverrides(overrides as JsonValue | undefined); if (isInitializingRef.current && !pendingData) { isInitializingRef.current = false; - if (!baselineFormData) { - // Always use formData (server data + schema defaults) for the - // baseline snapshot, NOT sanitizedData from the onChange callback. - // If a custom component (e.g., zone checkboxes) triggers onChange - // before RJSF's initial onChange, sanitizedData would include the - // user's modification, corrupting the baseline. - const baselineSnapshot = cloneDeep(formData as ConfigSectionData); - setBaselineFormData(baselineSnapshot); - nextBaselineFormData = baselineSnapshot; - } if (overrides === undefined) { setPendingData(null); setPendingOverrides(undefined); @@ -392,14 +407,12 @@ export function ConfigSection({ setPendingData, setPendingOverrides, setDirtyOverrides, - baselineFormData, - setBaselineFormData, - formData, + baselineSnapshot, ], ); const currentFormData = pendingData || formData; - const effectiveBaselineFormData = baselineFormData ?? formData; + const effectiveBaselineFormData = baselineSnapshot; const currentOverrides = useMemo(() => { if (!currentFormData || typeof currentFormData !== "object") { diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index d8c82d657..8a6691af0 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -6,7 +6,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { Children, useState, useEffect } from "react"; +import { Children, useState, useEffect, useRef } from "react"; import type { ReactNode } from "react"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { useTranslation } from "react-i18next"; @@ -142,6 +142,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { const hasModifiedDescendants = checkSubtreeModified(fieldPath); const [isOpen, setIsOpen] = useState(hasModifiedDescendants); + const resetKey = `${formContext?.level ?? "global"}::${ + formContext?.cameraName ?? "global" + }`; + const lastResetKeyRef = useRef(null); // Auto-expand collapsible when modifications are detected useEffect(() => { @@ -191,6 +195,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { setShowAdvanced(true); } }, [hasModifiedAdvanced]); + + useEffect(() => { + if (lastResetKeyRef.current !== resetKey) { + lastResetKeyRef.current = resetKey; + setIsOpen(hasModifiedDescendants); + setShowAdvanced(hasModifiedAdvanced); + } + }, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]); const { children } = props as ObjectFieldTemplateProps & { children?: ReactNode; };