From 173652e6721824e0038a1d605ec34ecfefd1848b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 22 May 2026 07:56:12 -0500 Subject: [PATCH] fix stale field message after reverting a conditional form field Routes field-level conditional messages through a dedicated React Context instead of merging them into uiSchema. RJSF's Form keeps state.uiSchema sticky across renders during processPendingChange (formData is updated, uiSchema is not), so a previously injected ui:messages array stays attached to a field even after the triggering condition flips back to false. Context propagation re-runs FieldTemplate directly on every provider value change, sidestepping that staleness. --- .../config-form/FieldMessagesContext.ts | 13 ++ .../config-form/sections/BaseSection.tsx | 147 +++++++----------- .../theme/templates/FieldTemplate.tsx | 24 ++- 3 files changed, 79 insertions(+), 105 deletions(-) create mode 100644 web/src/components/config-form/FieldMessagesContext.ts diff --git a/web/src/components/config-form/FieldMessagesContext.ts b/web/src/components/config-form/FieldMessagesContext.ts new file mode 100644 index 0000000000..5d45f7d79b --- /dev/null +++ b/web/src/components/config-form/FieldMessagesContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; +import type { FieldConditionalMessage } from "./section-configs/types"; + +// Provides currently-active field messages to FieldTemplate without going +// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders +// in a way that can leave stale ui:messages attached to a field when the +// triggering condition flips back to false (see processPendingChange in +// @rjsf/core Form.js — formData is updated immediately, uiSchema is not). +// useContext re-runs consumers directly on provider value change, sidestepping +// that staleness. +export const FieldMessagesContext = createContext( + [], +); diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index b4b566fc51..5bacd2d80c 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -86,6 +86,7 @@ import type { } from "../section-configs/types"; import { useConfigMessages } from "@/hooks/use-config-messages"; import { ConfigMessageBanner } from "../ConfigMessageBanner"; +import { FieldMessagesContext } from "../FieldMessagesContext"; export interface SectionConfig { /** Field ordering within the section */ @@ -627,44 +628,6 @@ export function ConfigSection({ messageContext, ); - // Merge field-level conditional messages into uiSchema - const effectiveUiSchema = useMemo(() => { - if (activeFieldMessages.length === 0) return sectionConfig.uiSchema; - const merged = { ...(sectionConfig.uiSchema ?? {}) }; - for (const msg of activeFieldMessages) { - const segments = msg.field.split("."); - // Navigate to the nested uiSchema node, shallow-cloning along the way - let node = merged; - for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]; - node[seg] = { ...(node[seg] as Record) }; - node = node[seg] as Record; - } - const leafKey = segments[segments.length - 1]; - const existing = node[leafKey] as Record | undefined; - const existingMessages = ((existing?.["ui:messages"] as unknown[]) ?? - []) as Array<{ - key: string; - messageKey: string; - severity: string; - position?: string; - }>; - node[leafKey] = { - ...existing, - "ui:messages": [ - ...existingMessages, - { - key: msg.key, - messageKey: msg.messageKey, - severity: msg.severity, - position: msg.position ?? "before", - }, - ], - }; - } - return merged; - }, [sectionConfig.uiSchema, activeFieldMessages]); - const currentOverrides = useMemo(() => { if (!currentFormData || typeof currentFormData !== "object") { return undefined; @@ -1034,59 +997,61 @@ export function ConfigSection({ const sectionContent = (
- handleChange(data), - // For widgets that need access to full camera config (e.g., zone names) - fullCameraConfig: - effectiveLevel === "camera" && cameraName - ? config?.cameras?.[cameraName] - : undefined, - fullConfig: config, - // When rendering camera-level sections, provide the section path so - // field templates can look up keys under the `config/cameras` namespace - // When using a consolidated global namespace, keys are nested - // under the section name (e.g., `audio.label`) so provide the - // section prefix to templates so they can attempt `${section}.${field}` lookups. - sectionI18nPrefix: sectionPath, - t, - renderers: wrappedRenderers, - sectionDocs: sectionConfig.sectionDocs, - fieldDocs: sectionConfig.fieldDocs, - hiddenFields: effectiveHiddenFields, - restartRequired: sectionConfig.restartRequired, - requiresRestart, - isProfile: !!profileName, - }} - /> + + handleChange(data), + // For widgets that need access to full camera config (e.g., zone names) + fullCameraConfig: + effectiveLevel === "camera" && cameraName + ? config?.cameras?.[cameraName] + : undefined, + fullConfig: config, + // When rendering camera-level sections, provide the section path so + // field templates can look up keys under the `config/cameras` namespace + // When using a consolidated global namespace, keys are nested + // under the section name (e.g., `audio.label`) so provide the + // section prefix to templates so they can attempt `${section}.${field}` lookups. + sectionI18nPrefix: sectionPath, + t, + renderers: wrappedRenderers, + sectionDocs: sectionConfig.sectionDocs, + fieldDocs: sectionConfig.fieldDocs, + hiddenFields: effectiveHiddenFields, + restartRequired: sectionConfig.restartRequired, + requiresRestart, + isProfile: !!profileName, + }} + /> + {!embedded && (
{children}
; @@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) { const beforeContent = renderCustom(beforeSpec); const afterContent = renderCustom(afterSpec); - // Render conditional field messages from ui:messages - const fieldMessageSpecs = uiSchema?.["ui:messages"] as - | Array<{ - key: string; - messageKey: string; - severity: string; - position?: string; - }> - | undefined; - const beforeMessages = fieldMessageSpecs?.filter( + // Read field-level conditional messages from FieldMessagesContext + const fieldPathStr = pathSegments.join("."); + const fieldMessageSpecs = allFieldMessages.filter( + (m) => m.field === fieldPathStr, + ); + const beforeMessages = fieldMessageSpecs.filter( (m) => (m.position ?? "before") === "before", ); - const afterMessages = fieldMessageSpecs?.filter( - (m) => m.position === "after", - ); + const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after"); const beforeMessagesContent = beforeMessages && beforeMessages.length > 0 ? (