import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { FieldProps, FormContextType, RJSFSchema, StrictRJSFSchema, UiSchema } from '@rjsf/utils'; import { ANY_OF_KEY, deepEquals, ERRORS_KEY, getDiscriminatorFieldFromSchema, getTemplate, getUiOptions, getWidget, hashObject, isFormDataAvailable, mergeSchemas, ONE_OF_KEY, shouldRenderOptionalField, TranslatableString, } from '@rjsf/utils'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import omit from 'lodash/omit'; /** The `AnyOfField` component is used to render a field in the schema that is an `anyOf`, `allOf` or `oneOf`. It tracks * the currently selected option and cleans up any irrelevant data in `formData`. * * @param props - The `FieldProps` for this template */ function AnyOfField( props: FieldProps, ) { const { name, disabled = false, errorSchema = {}, formData, fieldPathId, onBlur, onChange, onFocus, options, readonly, registry, required = false, schema, uiSchema, } = props; const { schemaUtils } = registry; // Hash formData by value so the memo only invalidates when data actually changes, not on every // new object reference. hashObject(undefined) throws, so null is used as the fallback. const formDataHash = hashObject(formData ?? null); // retrievedOptions is purely derived from options — useMemo handles re-derivation automatically // when options, schemaUtils, or formData's value changes, with no render-phase dispatch needed. const retrievedOptions = useMemo( () => options.map((opt: S) => schemaUtils.retrieveSchema(opt, formData)), // oxlint-disable-next-line react-hooks/exhaustive-deps -- formDataHash is the value-stable proxy for formData [options, schemaUtils, formDataHash], ); const [selectedOption, setSelectedOption] = useState(() => { const discriminator = getDiscriminatorFieldFromSchema(schema); return schemaUtils.getClosestMatchingOption(formData, retrievedOptions, 0, discriminator); }); /** Flag to skip the formData-change-driven option recalculation when the user just selected an option. * Set to true in onOptionChange (before onChange is called), consumed and reset in the update effect. * This prevents the matching-option recalculation from overriding a user's explicit choice when * getDefaultFormState populates undefined properties that make deepEquals see a false formData change. */ const skipNextOptionRecalculation = useRef(false); const prevFormDataRef = useRef(formData); const prevFieldIdRef = useRef(fieldPathId.$id); // Mirrors componentDidUpdate: re-match selectedOption when formData changes on the same field. // Runs after every render (no deps array) to compare against prev values stored in refs. // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const prevFormData = prevFormDataRef.current; const prevFieldId = prevFieldIdRef.current; prevFormDataRef.current = formData; prevFieldIdRef.current = fieldPathId.$id; if (!deepEquals(formData, prevFormData) && fieldPathId.$id === prevFieldId) { if (skipNextOptionRecalculation.current) { skipNextOptionRecalculation.current = false; return; } const discriminator = getDiscriminatorFieldFromSchema(schema); const matchingOption = schemaUtils.getClosestMatchingOption( formData, retrievedOptions, selectedOption, discriminator, ); if (matchingOption !== selectedOption) { setSelectedOption(matchingOption); } } }); const fieldId = `${fieldPathId.$id}${schema.oneOf ? '__oneof_select' : '__anyof_select'}`; /** Callback handler to remember what the currently selected option is. In addition to that the `formData` is updated * to remove properties that are not part of the newly selected option schema, and then the updated data is passed to * the `onChange` handler. * * @param option - The new option value being selected */ const onOptionChange = useCallback( (option?: string) => { if (disabled || readonly) { return; } const intOption = option !== undefined ? parseInt(option, 10) : -1; if (intOption === selectedOption) { return; } const newOption = intOption >= 0 ? retrievedOptions[intOption] : undefined; const oldOption = selectedOption >= 0 ? retrievedOptions[selectedOption] : undefined; let newFormData = schemaUtils.sanitizeDataForNewSchema(newOption, oldOption, formData); if (newOption) { // Call getDefaultFormState to make sure defaults are populated on change. Pass "excludeObjectChildren" // so that only the root objects themselves are created without adding undefined children properties newFormData = schemaUtils.getDefaultFormState(newOption, newFormData, 'excludeObjectChildren') as T; } setSelectedOption(intOption); skipNextOptionRecalculation.current = true; onChange(newFormData, fieldPathId.path, undefined, fieldId); }, // setSelectedOption is stable (guaranteed by useState); skipNextOptionRecalculation is a ref [selectedOption, retrievedOptions, disabled, readonly, schemaUtils, formData, fieldPathId, onChange, fieldId], ); const { widgets, fields, translateString, globalUiOptions } = registry; const { SchemaField: SchemaFieldComponent } = fields; const MultiSchemaFieldTemplate = getTemplate<'MultiSchemaFieldTemplate', T, S, F>( 'MultiSchemaFieldTemplate', registry, globalUiOptions, ); const isOptionalRender = shouldRenderOptionalField(registry, schema, required, uiSchema); const hasFormData = isFormDataAvailable(formData); const { widget = 'select', placeholder, autofocus, autocomplete, title = schema.title, ...uiOptions } = getUiOptions(uiSchema, globalUiOptions); const Widget = getWidget({ type: 'number' }, widget, widgets); const rawErrors = get(errorSchema, ERRORS_KEY, []); const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]); const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); const option = selectedOption >= 0 ? retrievedOptions[selectedOption] || null : null; let optionSchema: S | undefined | null; if (option) { const { required: schemaRequired, type: schemaType } = schema; const parentProps: Partial = {}; if (schemaRequired) { parentProps.required = schemaRequired as S['required']; } // Propagate the parent schema type to options that don't define their own. // This is necessary when the parent constrains the type (e.g. { type: 'string', // oneOf: [{ pattern: '...' }, { pattern: '...' }] }) but the option sub-schemas // omit the type — without it, getSchemaType returns undefined and the option // renders as FallbackField instead of the correct widget (e.g. StringField). if (schemaType !== undefined && !('type' in option)) { parentProps.type = schemaType; } // Merge in all the non-oneOf/anyOf properties and also skip the special ADDITIONAL_PROPERTY_FLAG property optionSchema = Object.keys(parentProps).length > 0 ? (mergeSchemas(parentProps, option) as S) : option; } // First we will check to see if there is an anyOf/oneOf override for the UI schema let optionsUiSchema: UiSchema[] = []; if (ONE_OF_KEY in schema && uiSchema && ONE_OF_KEY in uiSchema) { if (Array.isArray(uiSchema[ONE_OF_KEY])) { optionsUiSchema = uiSchema[ONE_OF_KEY]; } else { // oxlint-disable-next-line no-console console.warn(`uiSchema.oneOf is not an array for "${title || name}"`); } } else if (ANY_OF_KEY in schema && uiSchema && ANY_OF_KEY in uiSchema) { if (Array.isArray(uiSchema[ANY_OF_KEY])) { optionsUiSchema = uiSchema[ANY_OF_KEY]; } else { // oxlint-disable-next-line no-console console.warn(`uiSchema.anyOf is not an array for "${title || name}"`); } } // Then we pick the one that matches the selected option index, if one exists otherwise default to the main uiSchema let optionUiSchema = uiSchema; if (selectedOption >= 0 && optionsUiSchema.length > selectedOption) { optionUiSchema = optionsUiSchema[selectedOption]; } const translateEnum: TranslatableString = title ? TranslatableString.TitleOptionPrefix : TranslatableString.OptionPrefix; const translateParams = title ? [title] : []; const enumOptions = retrievedOptions.map((opt: { title?: string }, index: number) => { const { title: uiTitle = opt.title } = getUiOptions(optionsUiSchema[index]); return { label: uiTitle || translateString(translateEnum, translateParams.concat(String(index + 1))), value: index, }; }); const selector = !isOptionalRender || hasFormData ? ( = 0 ? selectedOption : undefined} options={{ enumOptions, ...uiOptions }} registry={registry} placeholder={placeholder} autocomplete={autocomplete} autofocus={autofocus} label={title ?? name} hideLabel={!displayLabel} readonly={readonly} /> ) : undefined; const optionsSchemaField = (optionSchema && optionSchema.type !== 'null' && ( )) || null; return ( ); } export default AnyOfField;