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.
This commit is contained in:
Josh Hawkins 2026-05-22 07:56:12 -05:00
parent 1cb8976b26
commit 173652e672
3 changed files with 79 additions and 105 deletions

View File

@ -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<FieldConditionalMessage[]>(
[],
);

View File

@ -86,6 +86,7 @@ import type {
} from "../section-configs/types"; } from "../section-configs/types";
import { useConfigMessages } from "@/hooks/use-config-messages"; import { useConfigMessages } from "@/hooks/use-config-messages";
import { ConfigMessageBanner } from "../ConfigMessageBanner"; import { ConfigMessageBanner } from "../ConfigMessageBanner";
import { FieldMessagesContext } from "../FieldMessagesContext";
export interface SectionConfig { export interface SectionConfig {
/** Field ordering within the section */ /** Field ordering within the section */
@ -627,44 +628,6 @@ export function ConfigSection({
messageContext, 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<string, unknown>) };
node = node[seg] as Record<string, unknown>;
}
const leafKey = segments[segments.length - 1];
const existing = node[leafKey] as Record<string, unknown> | 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(() => { const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") { if (!currentFormData || typeof currentFormData !== "object") {
return undefined; return undefined;
@ -1034,59 +997,61 @@ export function ConfigSection({
const sectionContent = ( const sectionContent = (
<div className="space-y-6"> <div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} /> <ConfigMessageBanner messages={activeMessages} />
<ConfigForm <FieldMessagesContext.Provider value={activeFieldMessages}>
key={formKey} <ConfigForm
schema={modifiedSchema} key={formKey}
formData={currentFormData} schema={modifiedSchema}
onChange={handleChange} formData={currentFormData}
onValidationChange={setHasValidationErrors} onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder} onValidationChange={setHasValidationErrors}
fieldGroups={sectionConfig.fieldGroups} fieldOrder={sectionConfig.fieldOrder}
hiddenFields={effectiveHiddenFields} fieldGroups={sectionConfig.fieldGroups}
advancedFields={sectionConfig.advancedFields} hiddenFields={effectiveHiddenFields}
liveValidate={sectionConfig.liveValidate} advancedFields={sectionConfig.advancedFields}
uiSchema={effectiveUiSchema} liveValidate={sectionConfig.liveValidate}
disabled={disabled || isSaving} uiSchema={sectionConfig.uiSchema}
readonly={readonly} disabled={disabled || isSaving}
showSubmit={false} readonly={readonly}
i18nNamespace={configNamespace} showSubmit={false}
customValidate={customValidate} i18nNamespace={configNamespace}
formContext={{ customValidate={customValidate}
level: effectiveLevel, formContext={{
cameraName, level: effectiveLevel,
globalValue, cameraName,
cameraValue, globalValue,
hasChanges, cameraValue,
extraHasChanges, hasChanges,
setExtraHasChanges, extraHasChanges,
overrides: uiOverrides as JsonValue | undefined, setExtraHasChanges,
formData: currentFormData as ConfigSectionData, overrides: uiOverrides as JsonValue | undefined,
baselineFormData: effectiveBaselineFormData as ConfigSectionData, formData: currentFormData as ConfigSectionData,
pendingDataBySection, baselineFormData: effectiveBaselineFormData as ConfigSectionData,
onPendingDataChange, pendingDataBySection,
onFormDataChange: (data: ConfigSectionData) => handleChange(data), onPendingDataChange,
// For widgets that need access to full camera config (e.g., zone names) onFormDataChange: (data: ConfigSectionData) => handleChange(data),
fullCameraConfig: // For widgets that need access to full camera config (e.g., zone names)
effectiveLevel === "camera" && cameraName fullCameraConfig:
? config?.cameras?.[cameraName] effectiveLevel === "camera" && cameraName
: undefined, ? config?.cameras?.[cameraName]
fullConfig: config, : undefined,
// When rendering camera-level sections, provide the section path so fullConfig: config,
// field templates can look up keys under the `config/cameras` namespace // When rendering camera-level sections, provide the section path so
// When using a consolidated global namespace, keys are nested // field templates can look up keys under the `config/cameras` namespace
// under the section name (e.g., `audio.label`) so provide the // When using a consolidated global namespace, keys are nested
// section prefix to templates so they can attempt `${section}.${field}` lookups. // under the section name (e.g., `audio.label`) so provide the
sectionI18nPrefix: sectionPath, // section prefix to templates so they can attempt `${section}.${field}` lookups.
t, sectionI18nPrefix: sectionPath,
renderers: wrappedRenderers, t,
sectionDocs: sectionConfig.sectionDocs, renderers: wrappedRenderers,
fieldDocs: sectionConfig.fieldDocs, sectionDocs: sectionConfig.sectionDocs,
hiddenFields: effectiveHiddenFields, fieldDocs: sectionConfig.fieldDocs,
restartRequired: sectionConfig.restartRequired, hiddenFields: effectiveHiddenFields,
requiresRestart, restartRequired: sectionConfig.restartRequired,
isProfile: !!profileName, requiresRestart,
}} isProfile: !!profileName,
/> }}
/>
</FieldMessagesContext.Provider>
{!embedded && ( {!embedded && (
<div <div

View File

@ -5,8 +5,9 @@ import {
getUiOptions, getUiOptions,
ADDITIONAL_PROPERTY_FLAG, ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils"; } from "@rjsf/utils";
import { ComponentType, ReactNode } from "react"; import { ComponentType, ReactNode, useContext } from "react";
import { isValidElement } from "react"; import { isValidElement } from "react";
import { FieldMessagesContext } from "../../FieldMessagesContext";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -95,6 +96,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
"views/settings", "views/settings",
]); ]);
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const allFieldMessages = useContext(FieldMessagesContext);
if (hidden) { if (hidden) {
return <div className="hidden">{children}</div>; return <div className="hidden">{children}</div>;
@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) {
const beforeContent = renderCustom(beforeSpec); const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec); const afterContent = renderCustom(afterSpec);
// Render conditional field messages from ui:messages // Read field-level conditional messages from FieldMessagesContext
const fieldMessageSpecs = uiSchema?.["ui:messages"] as const fieldPathStr = pathSegments.join(".");
| Array<{ const fieldMessageSpecs = allFieldMessages.filter(
key: string; (m) => m.field === fieldPathStr,
messageKey: string; );
severity: string; const beforeMessages = fieldMessageSpecs.filter(
position?: string;
}>
| undefined;
const beforeMessages = fieldMessageSpecs?.filter(
(m) => (m.position ?? "before") === "before", (m) => (m.position ?? "before") === "before",
); );
const afterMessages = fieldMessageSpecs?.filter( const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
(m) => m.position === "after",
);
const beforeMessagesContent = const beforeMessagesContent =
beforeMessages && beforeMessages.length > 0 ? ( beforeMessages && beforeMessages.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">