diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 06116155b7..a602c6524e 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -1,19 +1,34 @@ -import { Component, ElementType, FormEvent, ReactNode, Ref, RefObject, createRef } from 'react'; import { - createSchemaUtils, - CustomValidator, + ElementType, + FormEvent, + ForwardedRef, + ReactElement, + ReactNode, + Ref, + RefObject, + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useReducer, + useRef, +} from 'react'; +import { deepEquals, ErrorSchema, ErrorSchemaBuilder, - ErrorTransformer, FieldPathId, FieldPathList, FormContextType, getChangedFields, + getFieldNames, getTemplate, getUiOptions, + getUsedFormData, isObject, - mergeObjects, PathSchema, StrictRJSFSchema, Registry, @@ -23,39 +38,41 @@ import { RJSFValidationError, removeOptionalEmptyObjects, SchemaUtilsType, - shouldRender, SUBMIT_BTN_OPTIONS_KEY, TemplatesType, toErrorList, toFieldPathId, UiSchema, - UI_DEFINITIONS_KEY, - UI_GLOBAL_OPTIONS_KEY, UI_OPTIONS_KEY, - ValidationData, - validationDataMerge, ValidatorType, Experimental_DefaultFormStateBehavior, Experimental_CustomMergeAllOf, - DEFAULT_ID_SEPARATOR, - DEFAULT_ID_PREFIX, - GlobalFormOptions, ERRORS_KEY, ID_KEY, NameGeneratorFunction, - getUsedFormData, - getFieldNames, + CustomValidator, + ErrorTransformer, + ValidationData, + validationDataMerge, } from '@rjsf/utils'; import _cloneDeep from 'lodash/cloneDeep'; import _get from 'lodash/get'; import _isEmpty from 'lodash/isEmpty'; -import _pick from 'lodash/pick'; import _set from 'lodash/set'; import _toPath from 'lodash/toPath'; import _unset from 'lodash/unset'; -import getDefaultRegistry from '../getDefaultRegistry'; import { ADDITIONAL_PROPERTY_KEY_REMOVE, IS_RESET } from './constants'; +import { + FormAction, + formReducer, + getStateFromProps, + mergeErrors, + performLiveValidate, + propsAreEqual, + runValidation, + toIChangeEvent, +} from './formUtils'; /** Represents a boolean option that is deprecated. * @deprecated - In a future major release, this type will be removed @@ -244,11 +261,11 @@ export interface FormProps>; + ref?: Ref>; } /** The data that is contained within the state for the `Form` */ @@ -314,7 +331,7 @@ export interface FormState; - /** Tracks the previous `extraErrors` prop reference so that `getDerivedStateFromProps` can detect changes */ + /** Tracks the previous `extraErrors` prop reference so that the derived-state logic can detect changes */ _prevExtraErrors?: ErrorSchema; } @@ -333,20 +350,28 @@ export interface IChangeEvent< status?: 'submitted'; } -/** Converts the full `FormState` into the `IChangeEvent` version by picking out the public values - * - * @param state - The state of the form - * @param status - The status provided by the onSubmit - * @returns - The `IChangeEvent` for the state - */ -function toIChangeEvent( - state: FormState, - status?: IChangeEvent['status'], -): IChangeEvent { - return { - ..._pick(state, ['schema', 'uiSchema', 'fieldPathId', 'schemaUtils', 'formData', 'edit', 'errors', 'errorSchema']), - ...(status !== undefined && { status }), - }; +/** The public handle exposed on the Form ref */ +export interface FormRef { + /** Programmatically submits the form */ + submit(): void; + /** Resets the form to its initial state */ + reset(): void; + /** Programmatically validates the form; returns true if valid */ + validateForm(): boolean; + /** Validates the form with specific formData; returns true if valid */ + validateFormWithFormData(formData?: T): boolean; + /** Sets a field value at the given path */ + setFieldValue(fieldPath: string | FieldPathList, newValue?: T): void; + /** @deprecated - use SchemaUtils.omitExtraData instead */ + omitExtraData(formData?: T): T | undefined; + /** @deprecated */ + getUsedFormData(formData: T | undefined, fields: string[]): T | undefined; + /** @deprecated */ + getFieldNames(pathSchema: PathSchema, formData?: T): string[][]; + /** Ref to the underlying form DOM element */ + formElement: RefObject; + /** The current form state */ + readonly state: FormState; } /** The definition of a pending change that will be processed in the `onChange` handler @@ -362,551 +387,297 @@ interface PendingChange { id?: string; } -/** The `Form` component renders the outer form and all the fields defined in the `schema` */ -export default class Form< - T = any, - S extends StrictRJSFSchema = RJSFSchema, - F extends FormContextType = any, -> extends Component, FormState> { +/** The inner implementation of the JSON Schema form. Accepts the full `FormProps` (minus the + * forwarded ref) and exposes an imperative `FormRef` handle via `forwardRef` so callers can + * programmatically submit, reset, or validate the form. + * + * @param props - All `FormProps` except `ref`; destructured internally for rendering and behaviour + * @param forwardedRef - The React ref that will be populated with the `FormRef` imperative handle + */ +function FormComponent( + props: Omit, 'ref'>, + forwardedRef: ForwardedRef>, +) { + const { + children, + id, + className = '', + tagName, + name, + method, + target, + action, + autoComplete, + enctype, + acceptCharset, + noHtml5Validate = false, + disabled, + readonly, + showErrorList = 'top', + _internalFormWrapper, + validator, + formData: propsFormData, + initialFormData, + } = props; + + if (!validator) { + throw new Error('A validator is required for Form functionality to work'); + } + + // ── Refs ────────────────────────────────────────────────────────────────── /** The ref used to hold the `form` element, this needs to be `any` because `tagName` or `_internalFormWrapper` can * provide any possible type here */ - formElement: RefObject; - - /** The list of pending changes - */ - pendingChanges: PendingChange[] = []; - - /** Flag to track when we're processing a user-initiated field change. - * This prevents componentDidUpdate from reverting oneOf/anyOf option switches. - */ - private _isProcessingUserChange = false; - - /** When the `extraErrors` prop changes, re-merges `schemaValidationErrors` + `extraErrors` + `customErrors` into - * state before render, ensuring the updated errors are visible immediately in a single render cycle. - * - * @param props - The current props - * @param state - The current state - * @returns Partial state with re-merged errors if `extraErrors` changed, or `null` if no update is needed - */ - static getDerivedStateFromProps( - props: FormProps, - state: FormState, - ): Partial> | null { - if (props.extraErrors !== state._prevExtraErrors) { - const baseErrors: ValidationData = { - errors: state.schemaValidationErrors || [], - errorSchema: (state.schemaValidationErrorSchema || {}) as ErrorSchema, - }; - let { errors, errorSchema } = baseErrors; - if (props.extraErrors) { - ({ errors, errorSchema } = validationDataMerge(baseErrors, props.extraErrors)); - } - if (state.customErrors) { - ({ errors, errorSchema } = validationDataMerge( - { errors, errorSchema }, - state.customErrors.ErrorSchema, - true, - )); - } - return { _prevExtraErrors: props.extraErrors, errors, errorSchema }; + const formElement = useRef(null); + const pendingChangesRef = useRef[]>([]); + const isProcessingUserChangeRef = useRef(false); + const handleRef = useRef | null>(null); + /** Always holds the latest props so callbacks/effects don't stale-close over old values */ + const propsRef = useRef(props); + propsRef.current = props; + + // ── State ───────────────────────────────────────────────────────────────── + const [state, dispatch] = useReducer( + formReducer as (s: FormState, a: FormAction) => FormState, + null as unknown as FormState, + (): FormState => { + const initFormData = propsFormData ?? initialFormData; + const initialState = getStateFromProps(props, {}, initFormData, undefined, undefined, [], true); + return { ...initialState, _prevExtraErrors: props.extraErrors }; + }, + ); + + /** Always holds the latest committed state for use inside callbacks */ + const stateRef = useRef(state); + stateRef.current = state; + + // ── Initial onChange call (constructor equivalent) ──────────────────────── + const didFireInitialOnChangeRef = useRef(false); + // useLayoutEffect fires before children's useEffect, ensuring the initial onChange + // (with id=undefined) is emitted before any child widget effects emit their own changes. + useLayoutEffect(() => { + if (didFireInitialOnChangeRef.current) { + return; } - return null; - } - - /** Constructs the `Form` from the `props`. Will setup the initial state from the props. It will also call the - * `onChange` handler if the initially provided `formData` is modified to add missing default values as part of the - * state construction. - * - * @param props - The initial props for the `Form` - */ - constructor(props: FormProps) { - super(props); - - if (!props.validator) { - throw new Error('A validator is required for Form functionality to work'); + didFireInitialOnChangeRef.current = true; + const initFormData = propsRef.current.formData ?? propsRef.current.initialFormData; + if (propsRef.current.onChange && !deepEquals(stateRef.current.formData, initFormData)) { + propsRef.current.onChange(toIChangeEvent(stateRef.current)); } - - const { formData: propsFormData, initialFormData, onChange } = props; - const formData = propsFormData ?? initialFormData; - this.state = { - ...this.getStateFromProps(props, formData, undefined, undefined, undefined, true), - _prevExtraErrors: props.extraErrors, + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // ── getDerivedStateFromProps equivalent ─────────────────────────────────── + // Re-merges schemaValidationErrors + extraErrors + customErrors whenever + // the `extraErrors` prop reference changes. + const prevExtraErrorsRef = useRef(props.extraErrors); + useEffect(() => { + if (props.extraErrors === prevExtraErrorsRef.current) { + return; + } + prevExtraErrorsRef.current = props.extraErrors; + const s = stateRef.current; + const base: ValidationData = { + errors: s.schemaValidationErrors || [], + errorSchema: (s.schemaValidationErrorSchema || {}) as ErrorSchema, }; - if (onChange && !deepEquals(this.state.formData, formData)) { - onChange(toIChangeEvent(this.state)); + let { errors, errorSchema } = base; + if (props.extraErrors) { + ({ errors, errorSchema } = validationDataMerge(base, props.extraErrors)); } - this.formElement = createRef(); - } - - /** - * `getSnapshotBeforeUpdate` is a React lifecycle method that is invoked right before the most recently rendered - * output is committed to the DOM. It enables your component to capture current values (e.g., scroll position) before - * they are potentially changed. - * - * In this case, it checks if the props have changed since the last render. If they have, it computes the next state - * of the component using `getStateFromProps` method and returns it along with a `shouldUpdate` flag set to `true` IF - * the `nextState` and `prevState` are different, otherwise `false`. This ensures that we have the most up-to-date - * state ready to be applied in `componentDidUpdate`. - * - * If `formData` hasn't changed, it simply returns an object with `shouldUpdate` set to `false`, indicating that a - * state update is not necessary. - * - * @param prevProps - The previous set of props before the update. - * @param prevState - The previous state before the update. - * @returns Either an object containing the next state and a flag indicating that an update should occur, or an object - * with a flag indicating that an update is not necessary. - */ - getSnapshotBeforeUpdate( - prevProps: FormProps, - prevState: FormState, - ): { nextState: FormState; shouldUpdate: true } | { shouldUpdate: false } { - if (!deepEquals(this.props, prevProps)) { - // Compare the previous props formData against the current props formData - const formDataChangedFields = getChangedFields(this.props.formData, prevProps.formData); - // Compare the current props formData against the current state's formData to determine if the new props were the - // result of the onChange from the existing state formData - const stateDataChangedFields = getChangedFields(this.props.formData, this.state.formData); - const isSchemaChanged = !deepEquals(prevProps.schema, this.props.schema); - // When formData is not an object, getChangedFields returns an empty array. - // In this case, deepEquals is most needed to check again. - const isFormDataChanged = - formDataChangedFields.length > 0 || !deepEquals(prevProps.formData, this.props.formData); - const isStateDataChanged = - stateDataChangedFields.length > 0 || !deepEquals(this.state.formData, this.props.formData); - const nextState = this.getStateFromProps( - this.props, - this.props.formData, - // If the `schema` has changed, we need to update the retrieved schema. - // Or if the `formData` changes, for example in the case of a schema with dependencies that need to - // match one of the subSchemas, the retrieved schema must be updated. - isSchemaChanged || isFormDataChanged ? undefined : this.state.retrievedSchema, - isSchemaChanged, - formDataChangedFields, - // Skip live validation for this request if no form data has changed from the last state - !isStateDataChanged, - ); - const shouldUpdate = !deepEquals(nextState, prevState); - return { nextState, shouldUpdate }; + if (s.customErrors) { + ({ errors, errorSchema } = validationDataMerge({ errors, errorSchema }, s.customErrors.ErrorSchema, true)); + } + dispatch({ type: 'SET_ERRORS', payload: { _prevExtraErrors: props.extraErrors, errors, errorSchema } }); + }, [props.extraErrors]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── getSnapshotBeforeUpdate + componentDidUpdate equivalent ─────────────── + // Runs after every render; acts only when props have genuinely changed. + const prevPropsRef = useRef, 'ref'>>(props); + useEffect(() => { + const prevProps = prevPropsRef.current; + prevPropsRef.current = props; + if (deepEquals(props, prevProps)) { + return; } - return { shouldUpdate: false }; - } - /** - * `componentDidUpdate` is a React lifecycle method that is invoked immediately after updating occurs. This method is - * not called for the initial render. - * - * Here, it checks if an update is necessary based on the `shouldUpdate` flag received from `getSnapshotBeforeUpdate`. - * If an update is required, it applies the next state and, if needed, triggers the `onChange` handler to inform about - * changes. - * - * @param _ - The previous set of props. - * @param prevState - The previous state of the component before the update. - * @param snapshot - The value returned from `getSnapshotBeforeUpdate`. - */ - componentDidUpdate( - _: FormProps, - prevState: FormState, - snapshot: { nextState: FormState; shouldUpdate: true } | { shouldUpdate: false }, - ) { - if (snapshot.shouldUpdate) { - const { nextState } = snapshot; + const currentState = stateRef.current; + const formDataChangedFields = getChangedFields(props.formData, prevProps.formData); + const stateDataChangedFields = getChangedFields(props.formData, currentState.formData); + const isSchemaChanged = !deepEquals(prevProps.schema, props.schema); + const isFormDataChanged = formDataChangedFields.length > 0 || !deepEquals(prevProps.formData, props.formData); + const isStateDataChanged = stateDataChangedFields.length > 0 || !deepEquals(currentState.formData, props.formData); + + const nextState = getStateFromProps( + props, + currentState, + props.formData, + isSchemaChanged || isFormDataChanged ? undefined : currentState.retrievedSchema, + isSchemaChanged, + formDataChangedFields, + !isStateDataChanged, + ); - // Prevent oneOf/anyOf option switches from reverting when getStateFromProps - // re-evaluates and produces stale formData. - const nextStateDiffersFromProps = !deepEquals(nextState.formData, this.props.formData); - const wasProcessingUserChange = this._isProcessingUserChange; - this._isProcessingUserChange = false; + if (!deepEquals(nextState, currentState)) { + const nextStateDiffersFromProps = !deepEquals(nextState.formData, props.formData); + const wasProcessingUserChange = isProcessingUserChangeRef.current; + isProcessingUserChangeRef.current = false; if (wasProcessingUserChange && nextStateDiffersFromProps) { - // Skip - the user's option switch is already applied via processPendingChange + // Skip — the user's oneOf/anyOf option switch is already applied via processPendingChange return; } - - if (nextStateDiffersFromProps && !deepEquals(nextState.formData, prevState.formData) && this.props.onChange) { - this.props.onChange(toIChangeEvent(nextState)); + if (nextStateDiffersFromProps && !deepEquals(nextState.formData, currentState.formData)) { + propsRef.current.onChange?.(toIChangeEvent(nextState)); } - this.setState(nextState); + // Eagerly update stateRef so that parent useEffect hooks (e.g. calling validateForm()) + // see the updated state before the dispatch triggers a re-render. + stateRef.current = nextState; + dispatch({ type: 'SET_STATE', payload: nextState }); } - } + }); // no dep array — compares against prevPropsRef manually + + // ── Imperative helpers ──────────────────────────────────────────────────── - /** Extracts the updated state from the given `props` and `inputFormData`. As part of this process, the - * `inputFormData` is first processed to add any missing required defaults. After that, the data is run through the - * validation process IF required by the `props`. + /** Removes form data fields that do not appear in the current schema. * - * @param props - The props passed to the `Form` - * @param inputFormData - The new or current data for the `Form` - * @param retrievedSchema - An expanded schema, if not provided, it will be retrieved from the `schema` and `formData`. - * @param isSchemaChanged - A flag indicating whether the schema has changed. - * @param formDataChangedFields - The changed fields of `formData` - * @param skipLiveValidate - Optional flag, if true, means that we are not running live validation - * @returns - The new state for the `Form` + * @param formData - The form data to filter; defaults to `undefined` + * @returns - The filtered form data with extra fields removed, or `undefined` if nothing remains */ - getStateFromProps( - props: FormProps, - inputFormData?: T, - retrievedSchema?: S, - isSchemaChanged = false, - formDataChangedFields: string[] = [], - skipLiveValidate = false, - ): FormState { - const state: FormState = this.state || {}; - const schema = 'schema' in props ? props.schema : this.props.schema; - const validator = 'validator' in props ? props.validator : this.props.validator; - const uiSchema: UiSchema = ('uiSchema' in props ? props.uiSchema! : this.props.uiSchema!) || {}; - const isUncontrolled = props.formData === undefined && this.props.formData === undefined; - const edit = typeof inputFormData !== 'undefined'; - const liveValidate = 'liveValidate' in props ? props.liveValidate : this.props.liveValidate; - const mustValidate = edit && !props.noValidate && liveValidate; - const experimental_defaultFormStateBehavior = - 'experimental_defaultFormStateBehavior' in props - ? props.experimental_defaultFormStateBehavior - : this.props.experimental_defaultFormStateBehavior; - const experimental_customMergeAllOf = - 'experimental_customMergeAllOf' in props - ? props.experimental_customMergeAllOf - : this.props.experimental_customMergeAllOf; - let schemaUtils: SchemaUtilsType = state.schemaUtils; - if ( - !schemaUtils || - schemaUtils.doesSchemaUtilsDiffer( - validator, - schema, - experimental_defaultFormStateBehavior, - experimental_customMergeAllOf, - ) - ) { - schemaUtils = createSchemaUtils( - validator, - schema, - experimental_defaultFormStateBehavior, - experimental_customMergeAllOf, - ); + const omitExtraDataFn = useCallback((formData?: T): T | undefined => { + const { schema: s, schemaUtils } = stateRef.current; + return schemaUtils.omitExtraData(s, formData); + }, []); + + /** Moves browser focus to the form field associated with the given validation error. Derives the + * element id from the error's `property` path combined with the form's `idPrefix` and + * `idSeparator` settings. + * + * @param error - The validation error whose `property` path is used to locate the field element + */ + const focusOnError = useCallback((error: RJSFValidationError) => { + const { idPrefix = 'root', idSeparator = '_' } = propsRef.current; + const path = _toPath(error.property); + if (path[0] === '') { + path[0] = idPrefix; + } else { + path.unshift(idPrefix); } - - const rootSchema = schemaUtils.getRootSchema(); - - // Compute the formData for getDefaultFormState() function based on the inputFormData, isUncontrolled and state - let defaultsFormData = inputFormData; - if (inputFormData === IS_RESET) { - defaultsFormData = undefined; - } else if (inputFormData === undefined && isUncontrolled) { - defaultsFormData = state.formData; + const elementId = path.join(idSeparator); + let field = formElement.current?.elements[elementId]; + if (!field) { + field = formElement.current?.querySelector(`input[id^="${elementId}"], button[id^="${elementId}"]`); } - const formData: T = schemaUtils.getDefaultFormState( - rootSchema, - defaultsFormData, - false, - state.initialDefaultsGenerated, - ) as T; - const _retrievedSchema = this.updateRetrievedSchema( - retrievedSchema ?? schemaUtils.retrieveSchema(rootSchema, formData), - ); + if (field && field.length) { + field = field[0]; + } + field?.focus(); + }, []); - const getCurrentErrors = (): ValidationData => { - // If the `props.noValidate` option is set or the schema has changed, we reset the error state. - if (props.noValidate || isSchemaChanged) { - return { errors: [], errorSchema: {} }; - } else if (!props.liveValidate) { - return { - errors: state.schemaValidationErrors || [], - errorSchema: state.schemaValidationErrorSchema || {}, - }; - } - return { - errors: state.errors || [], - errorSchema: state.errorSchema || {}, - }; - }; + /** Runs full schema validation against the supplied form data, dispatches error state, and + * optionally focuses the first error field. Merges in any `extraErrors` from props before + * deciding whether the form is valid. + * + * @param formData - The form data to validate; when omitted the current state's data is used + * @returns - `true` if the form is valid (no schema errors and no blocking extra errors); + * `false` otherwise + */ + const validateFormWithFormData = useCallback( + (formData?: T): boolean => { + const { extraErrors, extraErrorsBlockSubmit, focusOnFirstError, onError, customValidate, transformErrors } = + propsRef.current; + const { errors: prevErrors, schema: s, schemaUtils, uiSchema, retrievedSchema } = stateRef.current; - let errors: RJSFValidationError[]; - let errorSchema: ErrorSchema | undefined; - let schemaValidationErrors: RJSFValidationError[] = state.schemaValidationErrors; - let schemaValidationErrorSchema: ErrorSchema = state.schemaValidationErrorSchema; - // If we are skipping live validate, it means that the state has already been updated with live validation errors - if (mustValidate && !skipLiveValidate) { - const liveValidation = this.liveValidate( - rootSchema, - schemaUtils, - state.errorSchema, + const schemaValidation = runValidation( formData, - undefined, - state.customErrors, + s, + schemaUtils, + customValidate, + transformErrors, + uiSchema, retrievedSchema, - // If retrievedSchema is undefined which means the schema or formData has changed, we do not merge state. - // Else in the case where it hasn't changed, - retrievedSchema !== undefined, ); - errors = liveValidation.errors; - errorSchema = liveValidation.errorSchema; - schemaValidationErrors = liveValidation.schemaValidationErrors; - schemaValidationErrorSchema = liveValidation.schemaValidationErrorSchema; - } else { - const currentErrors = getCurrentErrors(); - errors = currentErrors.errors; - errorSchema = currentErrors.errorSchema; - // We only update the error schema for changed fields if mustValidate is false - if (formDataChangedFields.length > 0 && !mustValidate) { - const newErrorSchema = formDataChangedFields.reduce( - (acc, key) => { - acc[key] = undefined; - return acc; + const { errors, errorSchema } = extraErrors ? mergeErrors(schemaValidation, extraErrors) : schemaValidation; + const hasError = schemaValidation.errors.length > 0 || (extraErrors && extraErrorsBlockSubmit); + + if (hasError) { + if (focusOnFirstError) { + if (typeof focusOnFirstError === 'function') { + focusOnFirstError(errors[0]); + } else { + focusOnError(errors[0]); + } + } + dispatch({ + type: 'SET_ERRORS', + payload: { + errors, + errorSchema, + schemaValidationErrors: schemaValidation.errors, + schemaValidationErrorSchema: schemaValidation.errorSchema, }, - {} as Record, - ); - errorSchema = schemaValidationErrorSchema = mergeObjects( - currentErrors.errorSchema, - newErrorSchema, - 'preventDuplicates', - ) as ErrorSchema; + }); + if (onError) { + onError(errors); + } else { + console.error('Form validation failed', errors); + } + } else if (errors.length > 0) { + dispatch({ + type: 'SET_ERRORS', + payload: { errors, errorSchema, schemaValidationErrors: [], schemaValidationErrorSchema: {} }, + }); + } else if (prevErrors.length > 0) { + dispatch({ + type: 'SET_ERRORS', + payload: { errors: [], errorSchema: {}, schemaValidationErrors: [], schemaValidationErrorSchema: {} }, + }); } - const mergedErrors = this.mergeErrors({ errorSchema, errors }, props.extraErrors, state.customErrors); - errors = mergedErrors.errors; - errorSchema = mergedErrors.errorSchema; + return !hasError; + }, + [focusOnError], + ); + + // ── processPendingChange ────────────────────────────────────────────────── + // Stored in a ref so the function body can reference itself recursively + // without stale closure issues, while still seeing the latest propsRef/stateRef. + const processPendingChangeRef = useRef<(currentFormState: FormState) => void>(null!); + processPendingChangeRef.current = (currentFormState: FormState) => { + if (pendingChangesRef.current.length === 0) { + return; } + isProcessingUserChangeRef.current = true; - // Only store a new registry when the props cause a different one to be created - const newRegistry = this.getRegistry(props, rootSchema, schemaUtils); - const registry = deepEquals(state.registry, newRegistry) ? state.registry : newRegistry; + const { newValue, path, id, newErrorSchema } = pendingChangesRef.current[0]; + const { + extraErrors, + omitExtraData: omitExtraDataProp, + liveOmit, + noValidate, + liveValidate, + onChange, + removeEmptyOptionalObjects, + customValidate, + transformErrors, + } = propsRef.current; - // Only compute a new `fieldPathId` when the `idPrefix` is different than the existing fieldPathId's ID_KEY - const fieldPathId = - state.fieldPathId && state.fieldPathId?.[ID_KEY] === registry.globalFormOptions.idPrefix - ? state.fieldPathId - : toFieldPathId('', registry.globalFormOptions); - const nextState: FormState = { + const { + formData: oldFormData, schemaUtils, - schema: rootSchema, - uiSchema, + schema, fieldPathId, - formData, - edit, - errors, - errorSchema, - schemaValidationErrors, schemaValidationErrorSchema, - retrievedSchema: _retrievedSchema, - initialDefaultsGenerated: true, - registry, - }; - return nextState; - } - - /** React lifecycle method that is used to determine whether component should be updated. - * - * @param nextProps - The next version of the props - * @param nextState - The next version of the state - * @returns - True if the component should be updated, false otherwise - */ - shouldComponentUpdate(nextProps: FormProps, nextState: FormState): boolean { - const { experimental_componentUpdateStrategy = 'customDeep' } = this.props; - return shouldRender(this, nextProps, nextState, experimental_componentUpdateStrategy); - } - - /** Validates the `formData` against the `schema` using the `altSchemaUtils` (if provided otherwise it uses the - * `schemaUtils` in the state), returning the results. - * - * @param formData - The new form data to validate - * @param schema - The schema used to validate against - * @param [altSchemaUtils] - The alternate schemaUtils to use for validation - * @param [retrievedSchema] - An optionally retrieved schema for per - */ - validate( - formData: T | undefined, - schema = this.state.schema, - altSchemaUtils?: SchemaUtilsType, - retrievedSchema?: S, - ): ValidationData { - const schemaUtils = altSchemaUtils ? altSchemaUtils : this.state.schemaUtils; - const { customValidate, transformErrors, uiSchema } = this.props; - const resolvedSchema = retrievedSchema ?? schemaUtils.retrieveSchema(schema, formData); - return schemaUtils - .getValidator() - .validateFormData(formData, resolvedSchema, customValidate, transformErrors, uiSchema); - } - - /** Renders any errors contained in the `state` in using the `ErrorList`, if not disabled by `showErrorList`. */ - renderErrors(registry: Registry) { - const { errors, errorSchema, schema, uiSchema } = this.state; - const options = getUiOptions(uiSchema); - const ErrorListTemplate = getTemplate<'ErrorListTemplate', T, S, F>('ErrorListTemplate', registry, options); - - if (errors && errors.length) { - return ( - - ); - } - return null; - } - - /** Merges any `extraErrors` or `customErrors` into the given `schemaValidation` object, returning the result - * - * @param schemaValidation - The `ValidationData` object into which additional errors are merged - * @param [extraErrors] - The extra errors from the props - * @param [customErrors] - The customErrors from custom components - * @return - The `extraErrors` and `customErrors` merged into the `schemaValidation` - * @private - */ - private mergeErrors( - schemaValidation: ValidationData, - extraErrors?: FormProps['extraErrors'], - customErrors?: ErrorSchemaBuilder, - ): ValidationData { - let errorSchema: ErrorSchema = schemaValidation.errorSchema; - let errors: RJSFValidationError[] = schemaValidation.errors; - if (extraErrors) { - const merged = validationDataMerge(schemaValidation, extraErrors); - errorSchema = merged.errorSchema; - errors = merged.errors; - } - if (customErrors) { - const merged = validationDataMerge({ errors, errorSchema }, customErrors.ErrorSchema, true); - errorSchema = merged.errorSchema; - errors = merged.errors; - } - return { errors, errorSchema }; - } - - /** Performs live validation and then updates and returns the errors and error schemas by potentially merging in - * `extraErrors` and `customErrors`. - * - * @param rootSchema - The `rootSchema` from the state - * @param schemaUtils - The `SchemaUtilsType` from the state - * @param originalErrorSchema - The original `ErrorSchema` from the state - * @param [formData] - The new form data to validate - * @param [extraErrors] - The extra errors from the props - * @param [customErrors] - The customErrors from custom components - * @param [retrievedSchema] - An expanded schema, if not provided, it will be retrieved from the `schema` and `formData` - * @param [mergeIntoOriginalErrorSchema=false] - Optional flag indicating whether we merge into original schema - * @returns - An object containing `errorSchema`, `errors`, `schemaValidationErrors` and `schemaValidationErrorSchema` - * @private - */ - private liveValidate( - rootSchema: S, - schemaUtils: SchemaUtilsType, - originalErrorSchema: ErrorSchema, - formData?: T, - extraErrors?: FormProps['extraErrors'], - customErrors?: ErrorSchemaBuilder, - retrievedSchema?: S, - mergeIntoOriginalErrorSchema = false, - ) { - const schemaValidation = this.validate(formData, rootSchema, schemaUtils, retrievedSchema); - const errors = schemaValidation.errors; - let errorSchema = schemaValidation.errorSchema; - // We merge 'originalErrorSchema' with 'schemaValidation.errorSchema.'; This done to display the raised field error. - if (mergeIntoOriginalErrorSchema) { - errorSchema = mergeObjects( - originalErrorSchema, - schemaValidation.errorSchema, - 'preventDuplicates', - ) as ErrorSchema; - } - const schemaValidationErrors = errors; - const schemaValidationErrorSchema = errorSchema; - const mergedErrors = this.mergeErrors({ errorSchema, errors }, extraErrors, customErrors); - return { ...mergedErrors, schemaValidationErrors, schemaValidationErrorSchema }; - } - - /** Returns the `formData` with only the elements specified in the `fields` list - * - * @param formData - The data for the `Form` - * @param fields - The fields to keep while filtering - * @deprecated - To be removed as an exported `Form` function in a future release; there isn't a planned replacement - */ - getUsedFormData = (formData: T | undefined, fields: string[]): T | undefined => { - return getUsedFormData(formData, fields); - }; - - /** Returns the list of field names from inspecting the `pathSchema` as well as using the `formData` - * - * @param pathSchema - The `PathSchema` object for the form - * @param [formData] - The form data to use while checking for empty objects/arrays - * @deprecated - To be removed as an exported `Form` function in a future release; there isn't a planned replacement - */ - getFieldNames = (pathSchema: PathSchema, formData?: T): string[][] => { - return getFieldNames(pathSchema, formData); - }; - - /** Returns the `formData` after filtering to remove any extra data not in a form field - * - * @param formData - The data for the `Form` - * @returns The `formData` after omitting extra data - * @deprecated - To be removed as an exported `Form` function in a future release, use `SchemaUtils.omitExtraData` - * instead. - */ - omitExtraData = (formData?: T): T | undefined => { - const { schema, schemaUtils } = this.state; - return schemaUtils.omitExtraData(schema, formData); - }; - - /** Allows a user to set a value for the provided `fieldPath`, which must be either a dotted path to the field OR a - * `FieldPathList`. To set the root element, used either `''` or `[]` for the path. Passing undefined will clear the - * value in the field. - * - * @param fieldPath - Either a dotted path to the field or the `FieldPathList` to the field - * @param [newValue] - The new value for the field - */ - setFieldValue = (fieldPath: string | FieldPathList, newValue?: T) => { - const { registry } = this.state; - const path = Array.isArray(fieldPath) ? fieldPath : fieldPath.split('.'); - const fieldPathId = toFieldPathId('', registry.globalFormOptions, path); - this.onChange(newValue, path, undefined, fieldPathId[ID_KEY]); - }; - - /** Pushes the given change information into the `pendingChanges` array and then calls `processPendingChanges()` if - * the array only contains a single pending change. - * - * @param newValue - The new form data from a change to a field - * @param path - The path to the change into which to set the formData - * @param [newErrorSchema] - The new `ErrorSchema` based on the field change - * @param [id] - The id of the field that caused the change - */ - onChange = (newValue: T | undefined, path: FieldPathList, newErrorSchema?: ErrorSchema, id?: string) => { - this.pendingChanges.push({ newValue, path, newErrorSchema, id }); - if (this.pendingChanges.length === 1) { - this.processPendingChange(); - } - }; + errors, + uiSchema, + } = currentFormState; + let { customErrors, errorSchema: originalErrorSchema } = currentFormState; - /** Function to handle changes made to a field in the `Form`. This handler gets the first change from the - * `pendingChanges` list, containing the `newValue` for the `formData` and the `path` at which the `newValue` is to be - * updated, along with a new, optional `ErrorSchema` for that same `path` and potentially the `id` of the field being - * changed. It will first update the `formData` with any missing default fields and then, if `omitExtraData` and - * `liveOmit` are turned on, the `formData` will be filtered to remove any extra data not in a form field. Then, the - * resulting `formData` will be validated if required. The state will be updated with the new updated (potentially - * filtered) `formData`, any errors that resulted from validation. Finally the `onChange` callback will be called, if - * specified, with the updated state and the `processPendingChange()` function is called again. - */ - processPendingChange() { - if (this.pendingChanges.length === 0) { - return; - } - // Mark that we're processing a user-initiated change. - // This prevents componentDidUpdate from reverting oneOf/anyOf option switches. - this._isProcessingUserChange = true; - const { newValue, path, id } = this.pendingChanges[0]; - const { newErrorSchema } = this.pendingChanges[0]; - const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange, removeEmptyOptionalObjects } = - this.props; - const { formData: oldFormData, schemaUtils, schema, fieldPathId, schemaValidationErrorSchema, errors } = this.state; - let { customErrors, errorSchema: originalErrorSchema } = this.state; const rootPathId = fieldPathId.path[0] || ''; - const isRootPath = !path || path.length === 0 || (path.length === 1 && path[0] === rootPathId); - let retrievedSchema = this.state.retrievedSchema; + let retrievedSchema = currentFormState.retrievedSchema; let formData = isRootPath ? newValue : _cloneDeep(oldFormData); - // When switching from null to an object option in oneOf, MultiSchemaField sends - // an object with property names but undefined values (e.g., {types: undefined, content: undefined}). - // In this case, pass undefined to getStateFromProps to trigger fresh default computation. - // Only do this when the previous formData was null/undefined (switching FROM null). + // When switching from null to an object option in oneOf, pass undefined to + // getStateFromProps to trigger fresh default computation. const hasOnlyUndefinedValues = isObject(formData) && Object.keys(formData as object).length > 0 && @@ -916,27 +687,30 @@ export default class Form< if (isObject(formData) || Array.isArray(formData)) { if (newValue === ADDITIONAL_PROPERTY_KEY_REMOVE) { - // For additional properties, we were given the special remove this key value, so unset it _unset(formData, path); } else if (!isRootPath) { - // If the newValue is not on the root path, then set it into the form data _set(formData, path, newValue); } - // Pass true to skip live validation in `getStateFromProps()` since we will do it a bit later - const newState = this.getStateFromProps(this.props, inputForDefaults, undefined, undefined, undefined, true); + const newState = getStateFromProps( + propsRef.current, + currentFormState, + inputForDefaults, + undefined, + undefined, + [], + true, + ); formData = newState.formData; retrievedSchema = newState.retrievedSchema; } const mustValidate = !noValidate && (liveValidate === true || liveValidate === 'onChange'); - let state: Partial> = { formData, schema }; + let partialState: Partial> = { formData, schema }; let newFormData = formData; - if (omitExtraData === true && (liveOmit === true || liveOmit === 'onChange')) { - newFormData = this.omitExtraData(formData); - state = { - formData: newFormData, - }; + if (omitExtraDataProp === true && (liveOmit === true || liveOmit === 'onChange')) { + newFormData = handleRef.current!.omitExtraData(formData); + partialState = { formData: newFormData }; } if (removeEmptyOptionalObjects) { @@ -946,19 +720,13 @@ export default class Form< schemaUtils.getRootSchema(), newFormData, ) as T; - state = { - ...state, - formData: newFormData, - }; + partialState = { ...partialState, formData: newFormData }; } if (newErrorSchema) { - // First check to see if there is an existing validation error on this path... // @ts-expect-error TS2590, because getting from the error schema is confusing TS const oldValidationError = !isRootPath ? _get(schemaValidationErrorSchema, path) : schemaValidationErrorSchema; - // If there is an old validation error for this path, assume we are updating it directly if (!_isEmpty(oldValidationError)) { - // Update the originalErrorSchema "in place" or replace it if it is the root if (!isRootPath) { _set(originalErrorSchema, path, newErrorSchema); } else { @@ -969,115 +737,100 @@ export default class Form< customErrors = new ErrorSchemaBuilder(); } if (isRootPath) { - const errors = _get(newErrorSchema, ERRORS_KEY); - if (errors) { - // only set errors when there are some - customErrors.setErrors(errors); + const errs = _get(newErrorSchema, ERRORS_KEY); + if (errs) { + customErrors.setErrors(errs); } } else { _set(customErrors.ErrorSchema, path, newErrorSchema); } } } else if (customErrors && _get(customErrors.ErrorSchema, [...path, ERRORS_KEY])) { - // If we have custom errors and the path has an error, then we need to clear it customErrors.clearErrors(path); } - // If there are pending changes in the queue, skip live validation since it will happen with the last change - if (mustValidate && this.pendingChanges.length === 1) { - const liveValidation = this.liveValidate( + + if (mustValidate && pendingChangesRef.current.length === 1) { + const liveValidation = performLiveValidate( schema, schemaUtils, - originalErrorSchema, + originalErrorSchema as ErrorSchema, newFormData, extraErrors, customErrors, retrievedSchema, + false, + customValidate, + transformErrors, + uiSchema, ); - state = { formData: newFormData, ...liveValidation, customErrors }; + partialState = { formData: newFormData, ...liveValidation, customErrors }; } else if (!noValidate && newErrorSchema) { - // Merging 'newErrorSchema' into 'errorSchema' to display the custom raised errors. - const mergedErrors = this.mergeErrors({ errorSchema: originalErrorSchema, errors }, extraErrors, customErrors); - state = { - formData: newFormData, - ...mergedErrors, - customErrors, - }; + const mergedErrs = mergeErrors({ errorSchema: originalErrorSchema, errors }, extraErrors, customErrors); + partialState = { formData: newFormData, ...mergedErrs, customErrors }; } - this.setState(state as FormState, () => { - if (onChange) { - onChange(toIChangeEvent({ ...this.state, ...state }), id); - } - // Now remove the change we just completed and call this again - this.pendingChanges.shift(); - this.processPendingChange(); - }); - } - /** - * If the retrievedSchema has changed the new retrievedSchema is returned. - * Otherwise, the old retrievedSchema is returned to persist reference. - * - This ensures that AJV retrieves the schema from the cache when it has not changed, - * avoiding the performance cost of recompiling the schema. - * - * @param retrievedSchema The new retrieved schema. - * @returns The new retrieved schema if it has changed, else the old retrieved schema. - */ - private updateRetrievedSchema(retrievedSchema: S) { - const isTheSame = deepEquals(retrievedSchema, this.state?.retrievedSchema); - return isTheSame ? this.state.retrievedSchema : retrievedSchema; - } + const mergedFormState: FormState = { ...currentFormState, ...partialState }; + // Update stateRef eagerly so that sibling effects firing before React commits this + // dispatch (e.g. two widgets changing in the same render batch) see the correct + // post-processing base state rather than the stale committed state. + stateRef.current = mergedFormState; + dispatch({ type: 'SET_STATE', payload: partialState }); + onChange?.(toIChangeEvent(mergedFormState), id); + pendingChangesRef.current.shift(); + processPendingChangeRef.current(mergedFormState); + }; - /** - * Callback function to handle reset form data. - * - Reset all fields with default values. - * - Reset validations and errors + // ── Callbacks ───────────────────────────────────────────────────────────── + + /** Enqueues a field value change and kicks off processing if no change is already in flight. + * Multiple rapid changes (e.g. two widgets updating in the same render cycle) are serialised + * through `pendingChangesRef` so each one is applied on top of the previous result. * + * @param newValue - The new value for the field at `path` + * @param path - The dot-separated path into the form data tree identifying the changed field + * @param newErrorSchema - Optional field-level errors to merge into the error schema + * @param id - Optional widget element id forwarded to the `onChange` callback */ - reset = () => { - // Cast the IS_RESET symbol to T to avoid type issues, we use this symbol to detect reset mode - const { formData: propsFormData, initialFormData = IS_RESET as T, onChange } = this.props; - const newState = this.getStateFromProps( - this.props, - propsFormData ?? initialFormData, - undefined, - undefined, - undefined, - true, - ); - const newFormData = newState.formData; - const state = { - formData: newFormData, - errorSchema: {}, - errors: [] as unknown, - schemaValidationErrors: [] as unknown, - schemaValidationErrorSchema: {}, - initialDefaultsGenerated: false, - customErrors: undefined, - } as FormState; - - this.setState(state, () => onChange && onChange(toIChangeEvent({ ...this.state, ...state }))); - }; + const handleChange = useCallback( + (newValue: T | undefined, path: FieldPathList, newErrorSchema?: ErrorSchema, id?: string) => { + pendingChangesRef.current.push({ newValue, path, newErrorSchema, id }); + if (pendingChangesRef.current.length === 1) { + processPendingChangeRef.current(stateRef.current); + } + }, + [], + ); - /** Callback function to handle when a field on the form is blurred. Calls the `onBlur` callback for the `Form` if it - * was provided. Also runs any live validation and/or live omit operations if the flags indicate they should happen - * during `onBlur`. + /** Handles a field blur event. Forwards the event to `onBlur` and, when `liveValidate` or + * `liveOmit` is set to `'onBlur'`, re-runs the appropriate omit/validate pass and dispatches + * updated state. * - * @param id - The unique `id` of the field that was blurred - * @param data - The data associated with the field that was blurred + * @param id - The element id of the field that lost focus + * @param data - The current value of the field at the time of the blur event */ - onBlur = (id: string, data: any) => { - const { onBlur, omitExtraData, liveOmit, liveValidate, removeEmptyOptionalObjects } = this.props; - if (onBlur) { - onBlur(id, data); - } - if ((omitExtraData === true && liveOmit === 'onBlur') || liveValidate === 'onBlur') { - const { onChange, extraErrors } = this.props; - const { formData, schemaUtils, schema } = this.state; + const handleBlur = useCallback((id: string, data: any) => { + const { + onBlur, + omitExtraData: omitExtraDataProp, + liveOmit, + liveValidate, + removeEmptyOptionalObjects, + onChange, + extraErrors, + customValidate, + transformErrors, + } = propsRef.current; + onBlur?.(id, data); + + if ((omitExtraDataProp === true && liveOmit === 'onBlur') || liveValidate === 'onBlur') { + const currentState = stateRef.current; + const { formData, schemaUtils, schema, errorSchema, customErrors, retrievedSchema, uiSchema } = currentState; let newFormData: T | undefined = formData; - let state: Partial> = { formData: newFormData }; - if (omitExtraData === true && liveOmit === 'onBlur') { - newFormData = this.omitExtraData(formData); - state = { formData: newFormData }; + let partialState: Partial> = { formData: newFormData }; + + if (omitExtraDataProp === true && liveOmit === 'onBlur') { + newFormData = schemaUtils.omitExtraData(schema, formData); + partialState = { formData: newFormData }; } if (removeEmptyOptionalObjects) { newFormData = removeOptionalEmptyObjects( @@ -1086,74 +839,73 @@ export default class Form< schemaUtils.getRootSchema(), newFormData, ) as T; - state = { ...state, formData: newFormData }; + partialState = { ...partialState, formData: newFormData }; } if (liveValidate === 'onBlur') { - const { schema, schemaUtils, errorSchema, customErrors, retrievedSchema } = this.state; - const liveValidation = this.liveValidate( + const liveValidation = performLiveValidate( schema, schemaUtils, - errorSchema, + errorSchema as ErrorSchema, newFormData, extraErrors, customErrors, retrievedSchema, + false, + customValidate, + transformErrors, + uiSchema, ); - state = { formData: newFormData, ...liveValidation, customErrors }; + partialState = { formData: newFormData, ...liveValidation, customErrors }; } - const hasChanges = Object.keys(state) - // Filter out `schemaValidationErrors` and `schemaValidationErrorSchema` since they aren't IChangeEvent props + + const hasChanges = Object.keys(partialState) .filter((key) => !key.startsWith('schemaValidation')) - .some((key) => { - const oldData = _get(this.state, key); - const newData = _get(state, key); - return !deepEquals(oldData, newData); - }); - this.setState(state as FormState, () => { - if (onChange && hasChanges) { - onChange(toIChangeEvent({ ...this.state, ...state }), id); - } - }); + .some((key) => !deepEquals(_get(currentState, key), _get(partialState, key))); + + dispatch({ type: 'SET_STATE', payload: partialState }); + if (onChange && hasChanges) { + onChange(toIChangeEvent({ ...currentState, ...partialState }), id); + } } - }; + }, []); - /** Callback function to handle when a field on the form is focused. Calls the `onFocus` callback for the `Form` if it - * was provided. + /** Handles a field focus event by forwarding it to the `onFocus` prop callback. * - * @param id - The unique `id` of the field that was focused - * @param data - The data associated with the field that was focused + * @param id - The element id of the field that received focus + * @param data - The current value of the field at the time of the focus event */ - onFocus = (id: string, data: any) => { - const { onFocus } = this.props; - if (onFocus) { - onFocus(id, data); - } - }; + const handleFocus = useCallback((id: string, data: any) => { + propsRef.current.onFocus?.(id, data); + }, []); - /** Callback function to handle when the form is submitted. First, it prevents the default event behavior. Nothing - * happens if the target and currentTarget of the event are not the same. It will omit any extra data in the - * `formData` in the state if `omitExtraData` is true. It will validate the resulting `formData`, reporting errors - * via the `onError()` callback unless validation is disabled. Finally, it will add in any `extraErrors` and then call - * back the `onSubmit` callback if it was provided. + /** Handles the native form `submit` event. Prevents the default browser submission, runs + * validation (unless `noValidate` is set), and — when the form is valid — dispatches clean + * error state and calls `onSubmit` with the current change event and the original DOM event. * - * @param event - The submit HTML form event + * @param event - The React form submit event from the underlying `
` element */ - onSubmit = (event: FormEvent) => { + const handleSubmit = useCallback((event: FormEvent) => { event.preventDefault(); if (event.target !== event.currentTarget) { return; } - event.persist(); - const { omitExtraData, extraErrors, noValidate, onSubmit, removeEmptyOptionalObjects } = this.props; - let { formData: newFormData } = this.state; - if (omitExtraData === true) { - newFormData = this.omitExtraData(newFormData); + const { + omitExtraData: omitExtraDataProp, + extraErrors, + noValidate, + onSubmit, + removeEmptyOptionalObjects, + } = propsRef.current; + const currentState = stateRef.current; + const { schemaUtils, schema } = currentState; + let { formData: newFormData } = currentState; + + if (omitExtraDataProp === true) { + newFormData = handleRef.current!.omitExtraData(newFormData); } - if (removeEmptyOptionalObjects) { - const { schemaUtils, schema } = this.state; newFormData = removeOptionalEmptyObjects( schemaUtils.getValidator(), schema, @@ -1162,279 +914,242 @@ export default class Form< ) as T; } - if (noValidate || this.validateFormWithFormData(newFormData)) { - // There are no errors generated through schema validation. - // Check for user provided errors and update state accordingly. - const errorSchema = extraErrors || {}; + if (noValidate || handleRef.current!.validateFormWithFormData(newFormData)) { + const errorSchema = (extraErrors || {}) as ErrorSchema; const errors = extraErrors ? toErrorList(extraErrors) : []; - this.setState( - { - formData: newFormData, - errors, - errorSchema, - schemaValidationErrors: [], - schemaValidationErrorSchema: {}, - }, - () => { - if (onSubmit) { - onSubmit(toIChangeEvent({ ...this.state, formData: newFormData }, 'submitted'), event); - } - }, + dispatch({ type: 'SET_FORM_DATA', payload: { formData: newFormData } }); + dispatch({ + type: 'SET_ERRORS', + payload: { errors, errorSchema, schemaValidationErrors: [], schemaValidationErrorSchema: {} }, + }); + onSubmit?.( + toIChangeEvent( + { + ...currentState, + formData: newFormData, + errors, + errorSchema, + schemaValidationErrors: [], + schemaValidationErrorSchema: {}, + }, + 'submitted', + ), + event, ); } - }; + }, []); - /** Extracts the `GlobalFormOptions` from the given Form `props` - * - * @param props - The form props to extract the global form options from - * @returns - The `GlobalFormOptions` from the props - * @private + /** Resets the form to its initial state. Recomputes the default form data from either the + * `formData` prop or `initialFormData`, clears all errors and custom errors, and fires + * `onChange` with the freshly-reset state. */ - private getGlobalFormOptions(props: FormProps): GlobalFormOptions { - const { - uiSchema = {}, - experimental_componentUpdateStrategy, - idSeparator = DEFAULT_ID_SEPARATOR, - idPrefix = DEFAULT_ID_PREFIX, - nameGenerator, - useFallbackUiForUnsupportedType = false, - } = props; - const rootFieldId = uiSchema['ui:rootFieldId']; - // Omit any options that are undefined or null - return { - idPrefix: rootFieldId || idPrefix, - idSeparator, - useFallbackUiForUnsupportedType, - ...(experimental_componentUpdateStrategy !== undefined && { experimental_componentUpdateStrategy }), - ...(nameGenerator !== undefined && { nameGenerator }), - }; - } - - /** Computed the registry for the form using the given `props`, `schema` and `schemaUtils` */ - getRegistry(props: FormProps, schema: S, schemaUtils: SchemaUtilsType): Registry { - const { translateString: customTranslateString, uiSchema = {} } = props; - const { fields, templates, widgets, formContext, translateString } = getDefaultRegistry(); - return { - fields: { ...fields, ...props.fields }, - templates: { - ...templates, - ...props.templates, - ButtonTemplates: { - ...templates.ButtonTemplates, - ...props.templates?.ButtonTemplates, - }, + const reset = useCallback(() => { + const { formData: pFormData, initialFormData: initData = IS_RESET as T, onChange } = propsRef.current; + const newState = getStateFromProps( + propsRef.current, + stateRef.current, + pFormData ?? initData, + undefined, + undefined, + [], + true, + ); + dispatch({ type: 'SET_FORM_DATA', payload: { formData: newState.formData, initialDefaultsGenerated: false } }); + dispatch({ + type: 'SET_ERRORS', + payload: { + errors: [] as RJSFValidationError[], + errorSchema: {} as ErrorSchema, + schemaValidationErrors: [] as RJSFValidationError[], + schemaValidationErrorSchema: {} as ErrorSchema, + customErrors: undefined, }, - widgets: { ...widgets, ...props.widgets }, - rootSchema: schema, - formContext: props.formContext || formContext, - schemaUtils, - translateString: customTranslateString || translateString, - globalUiOptions: uiSchema[UI_GLOBAL_OPTIONS_KEY], - globalFormOptions: this.getGlobalFormOptions(props), - uiSchemaDefinitions: uiSchema[UI_DEFINITIONS_KEY] ?? {}, - }; - } - - /** Provides a function that can be used to programmatically submit the `Form` */ - submit = () => { - if (this.formElement.current) { - const submitCustomEvent = new CustomEvent('submit', { - cancelable: true, - }); - submitCustomEvent.preventDefault(); - this.formElement.current.dispatchEvent(submitCustomEvent); - this.formElement.current.requestSubmit(); - } - }; - - /** Attempts to focus on the field associated with the `error`. Uses the `property` field to compute path of the error - * field, then, using the `idPrefix` and `idSeparator` converts that path into an id. Then the input element with that - * id is attempted to be found using the `formElement` ref. If it is located, then it is focused. - * - * @param error - The error on which to focus - */ - focusOnError(error: RJSFValidationError) { - const { idPrefix = 'root', idSeparator = '_' } = this.props; - const { property } = error; - const path = _toPath(property); - if (path[0] === '') { - // Most of the time the `.foo` property results in the first element being empty, so replace it with the idPrefix - path[0] = idPrefix; - } else { - // Otherwise insert the idPrefix into the first location using unshift - path.unshift(idPrefix); - } - - const elementId = path.join(idSeparator); - let field = this.formElement.current.elements[elementId]; - if (!field) { - // if not an exact match, try finding a focusable element starting with the element id (like radio buttons or checkboxes) - // some themes (e.g. shadcn) use button elements instead of native inputs for radio groups - field = this.formElement.current.querySelector(`input[id^="${elementId}"], button[id^="${elementId}"]`); - } - if (field && field.length) { - // If we got a list with length > 0 - field = field[0]; - } - if (field) { - field.focus(); - } - } - - /** Validates the form using the given `formData`. For use on form submission or on programmatic validation. - * If `onError` is provided, then it will be called with the list of errors. - * - * @param formData - The form data to validate - * @returns - True if the form is valid, false otherwise. - */ - validateFormWithFormData = (formData?: T): boolean => { - const { extraErrors, extraErrorsBlockSubmit, focusOnFirstError, onError } = this.props; - const { errors: prevErrors } = this.state; - const schemaValidation = this.validate(formData); - // Always merge extraErrors so they remain visible in state regardless of extraErrorsBlockSubmit. - const { errors, errorSchema } = extraErrors ? this.mergeErrors(schemaValidation, extraErrors) : schemaValidation; - // hasError gates submission: schema errors always block; extraErrors only block when - // extraErrorsBlockSubmit is set (non-breaking default: extraErrors are informational only). - const hasError = schemaValidation.errors.length > 0 || (extraErrors && extraErrorsBlockSubmit); - if (hasError) { - if (focusOnFirstError) { - if (typeof focusOnFirstError === 'function') { - focusOnFirstError(errors[0]); - } else { - this.focusOnError(errors[0]); - } - } - this.setState( - { - errors, - errorSchema, - schemaValidationErrors: schemaValidation.errors, - schemaValidationErrorSchema: schemaValidation.errorSchema, - }, - () => { - if (onError) { - onError(errors); - } else { - console.error('Form validation failed', errors); - } - }, - ); - } else if (errors.length > 0) { - // Non-blocking extraErrors are present — update display state without triggering onError. - this.setState({ - errors, - errorSchema, - schemaValidationErrors: [], - schemaValidationErrorSchema: {}, - }); - } else if (prevErrors.length > 0) { - this.setState({ + }); + onChange?.( + toIChangeEvent({ + ...stateRef.current, + formData: newState.formData, + initialDefaultsGenerated: false, errors: [], errorSchema: {}, schemaValidationErrors: [], schemaValidationErrorSchema: {}, - }); + }), + ); + }, []); + + /** Programmatically submits the form by dispatching a synthetic submit event on the underlying + * form element and then calling `requestSubmit()`, which respects native HTML5 constraint + * validation before triggering `handleSubmit`. + */ + const submit = useCallback(() => { + if (formElement.current) { + const submitEvent = new CustomEvent('submit', { cancelable: true }); + submitEvent.preventDefault(); + formElement.current.dispatchEvent(submitEvent); + formElement.current.requestSubmit(); } - return !hasError; - }; + }, []); - /** Programmatically validate the form. If `omitExtraData` is true, the `formData` will first be filtered to remove - * any extra data not in a form field. If `onError` is provided, then it will be called with the list of errors the - * same way as would happen on form submission. + /** Programmatically validates the form using the current state's form data. Applies + * `omitExtraData` and `removeEmptyOptionalObjects` pre-processing (when configured) before + * delegating to `validateFormWithFormData`. * - * @returns - True if the form is valid, false otherwise. + * @returns - `true` if the form is valid; `false` otherwise */ - validateForm() { - const { omitExtraData, removeEmptyOptionalObjects } = this.props; - let { formData: newFormData } = this.state; - if (omitExtraData === true) { - newFormData = this.omitExtraData(newFormData); + const validateForm = useCallback((): boolean => { + const { omitExtraData: omitExtraDataProp, removeEmptyOptionalObjects } = propsRef.current; + const { schema: s, schemaUtils } = stateRef.current; + let { formData: newFormData } = stateRef.current; + if (omitExtraDataProp === true) { + newFormData = schemaUtils.omitExtraData(s, newFormData); } if (removeEmptyOptionalObjects) { - const { schemaUtils, schema } = this.state; newFormData = removeOptionalEmptyObjects( schemaUtils.getValidator(), - schema, + s, schemaUtils.getRootSchema(), newFormData, ) as T; } - return this.validateFormWithFormData(newFormData); - } + return handleRef.current!.validateFormWithFormData(newFormData); + }, []); - /** Renders the `Form` fields inside the | `tagName` or `_internalFormWrapper`, rendering any errors if - * needed along with the submit button or any children of the form. + /** Sets the value of a single field identified by a dot-separated path string or a pre-split + * path array. Resolves the field's element id and delegates to `handleChange` so the change + * flows through the normal pending-change queue. + * + * @param fieldPath - Dot-separated path string (e.g. `'address.city'`) or a `FieldPathList` array + * @param newValue - The new value to set at the given path; omit or pass `undefined` to clear */ - render() { - const { - children, - id, - className = '', - tagName, - name, - method, - target, - action, - autoComplete, - enctype, - acceptCharset, - noHtml5Validate = false, - disabled, - readonly, - showErrorList = 'top', - _internalFormWrapper, - } = this.props; - - const { schema, uiSchema, formData, errorSchema, fieldPathId, registry } = this.state; - const { SchemaField: _SchemaField } = registry.fields; - const { SubmitButton } = registry.templates.ButtonTemplates; - // The `semantic-ui` and `material-ui` themes have `_internalFormWrapper`s that take an `as` prop that is the - // PropTypes.elementType to use for the inner tag, so we'll need to pass `tagName` along if it is provided. - // NOTE, the `as` prop is native to `semantic-ui` and is emulated in the `material-ui` theme - const as = _internalFormWrapper ? tagName : undefined; - const FormTag = _internalFormWrapper || tagName || 'form'; - - let { [SUBMIT_BTN_OPTIONS_KEY]: submitOptions = {} } = getUiOptions(uiSchema); - if (disabled) { - submitOptions = { ...submitOptions, props: { ...submitOptions.props, disabled: true } }; + const setFieldValue = useCallback( + (fieldPath: string | FieldPathList, newValue?: T) => { + const { registry } = stateRef.current; + const path = Array.isArray(fieldPath) ? fieldPath : fieldPath.split('.'); + const fpId = toFieldPathId('', registry.globalFormOptions, path); + handleChange(newValue, path, undefined, fpId[ID_KEY]); + }, + [handleChange], + ); + + // ── useImperativeHandle ─────────────────────────────────────────────────── + // Initialize the handle once so that jest.spyOn on the ref's methods isn't + // overwritten by re-renders. All methods delegate to stable useCallback refs. + if (!handleRef.current) { + handleRef.current = { + submit, + reset, + validateForm, + validateFormWithFormData, + setFieldValue, + omitExtraData: omitExtraDataFn, + getUsedFormData: (formData: T | undefined, fields: string[]) => getUsedFormData(formData, fields), + getFieldNames: (pathSchema: PathSchema, formData?: T) => getFieldNames(pathSchema, formData), + formElement, + get state() { + return stateRef.current; + }, + }; + } + // Empty deps: never recreate the handle so spy installations on ref.current survive re-renders. + useImperativeHandle(forwardedRef, () => handleRef.current!, []); + + // ── Render ──────────────────────────────────────────────────────────────── + const { + schema: stSchema, + uiSchema: stUiSchema, + formData: stFormData, + errorSchema: stErrorSchema, + fieldPathId: stFieldPathId, + registry, + } = state; + const { SchemaField: _SchemaField } = registry.fields; + const { SubmitButton } = registry.templates.ButtonTemplates; + + const as = _internalFormWrapper ? tagName : undefined; + const FormTag = _internalFormWrapper || tagName || 'form'; + + let { [SUBMIT_BTN_OPTIONS_KEY]: submitOptions = {} } = getUiOptions(stUiSchema); + if (disabled) { + submitOptions = { ...submitOptions, props: { ...submitOptions.props, disabled: true } }; + } + /** Memoised uiSchema fragment passed to `SubmitButton`; rebuilt only when the effective submit + * button options change (e.g. when `disabled` toggles or `ui:submitButtonOptions` changes). + */ + const submitUiSchema = useMemo( + () => ({ [UI_OPTIONS_KEY]: { [SUBMIT_BTN_OPTIONS_KEY]: submitOptions } }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(submitOptions)], + ); + + /** Memoised error list element rendered above or below the fields (controlled by + * `showErrorList`). Returns `null` when there are no errors. Re-renders only when the errors, + * error schema, root schema, uiSchema, or registry reference changes. + */ + const errorList = useMemo(() => { + if (!state.errors?.length) { + return null; } - const submitUiSchema = { [UI_OPTIONS_KEY]: { [SUBMIT_BTN_OPTIONS_KEY]: submitOptions } }; - + const options = getUiOptions(stUiSchema); + const ErrorListTemplate = getTemplate<'ErrorListTemplate', T, S, F>('ErrorListTemplate', registry, options); return ( - - {showErrorList === 'top' && this.renderErrors(registry)} - <_SchemaField - name='' - schema={schema} - uiSchema={uiSchema} - errorSchema={errorSchema} - fieldPathId={fieldPathId} - formData={formData} - onChange={this.onChange} - onBlur={this.onBlur} - onFocus={this.onFocus} - registry={registry} - disabled={disabled} - readonly={readonly} - /> - - {children ? children : } - {showErrorList === 'bottom' && this.renderErrors(registry)} - + ); - } + }, [state.errors, stErrorSchema, stSchema, stUiSchema, registry]); + + return ( + + {showErrorList === 'top' && errorList} + <_SchemaField + name='' + schema={stSchema} + uiSchema={stUiSchema} + errorSchema={stErrorSchema} + fieldPathId={stFieldPathId} + formData={stFormData} + onChange={handleChange} + onBlur={handleBlur} + onFocus={handleFocus} + registry={registry} + disabled={disabled} + readonly={readonly} + /> + {children ?? } + {showErrorList === 'bottom' && errorList} + + ); } + +// ─── Export ─────────────────────────────────────────────────────────────────── + +const FormWithRef = forwardRef(FormComponent) as < + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>( + props: FormProps, +) => ReactElement | null; + +const FormMemo = memo(FormWithRef, propsAreEqual) as typeof FormWithRef; + +export default FormMemo; diff --git a/packages/core/src/components/formUtils.ts b/packages/core/src/components/formUtils.ts new file mode 100644 index 0000000000..46f00890c7 --- /dev/null +++ b/packages/core/src/components/formUtils.ts @@ -0,0 +1,487 @@ +import { + createSchemaUtils, + CustomValidator, + deepEquals, + ErrorSchema, + ErrorSchemaBuilder, + ErrorTransformer, + FieldPathId, + FormContextType, + GlobalFormOptions, + ID_KEY, + mergeObjects, + Registry, + RJSFSchema, + RJSFValidationError, + SchemaUtilsType, + StrictRJSFSchema, + TemplatesType, + toFieldPathId, + UiSchema, + UI_DEFINITIONS_KEY, + UI_GLOBAL_OPTIONS_KEY, + ValidationData, + validationDataMerge, + DEFAULT_ID_SEPARATOR, + DEFAULT_ID_PREFIX, +} from '@rjsf/utils'; +import _pick from 'lodash/pick'; + +import getDefaultRegistry from '../getDefaultRegistry'; +import { IS_RESET } from './constants'; +import type { FormProps, FormState, IChangeEvent } from './Form'; + +// ─── IChangeEvent ───────────────────────────────────────────────────────────── + +/** Converts the full `FormState` into the `IChangeEvent` version by picking out the public values + * + * @param state - The state of the form + * @param status - The status provided by the onSubmit + * @returns - The `IChangeEvent` for the state + */ +export function toIChangeEvent( + state: FormState, + status?: IChangeEvent['status'], +): IChangeEvent { + return { + ..._pick(state, ['schema', 'uiSchema', 'fieldPathId', 'schemaUtils', 'formData', 'edit', 'errors', 'errorSchema']), + ...(status !== undefined && { status }), + }; +} + +// ─── Pure helper functions ──────────────────────────────────────────────────── + +/** Extracts the `GlobalFormOptions` from the given Form `props`. + * + * @param props - The form props from which to extract global options + * @returns - The `GlobalFormOptions` computed from the given props + */ +export function getGlobalFormOptions( + props: FormProps, +): GlobalFormOptions { + const { + uiSchema = {}, + experimental_componentUpdateStrategy, + idSeparator = DEFAULT_ID_SEPARATOR, + idPrefix = DEFAULT_ID_PREFIX, + nameGenerator, + useFallbackUiForUnsupportedType = false, + } = props; + const rootFieldId = uiSchema['ui:rootFieldId']; + return { + idPrefix: rootFieldId || idPrefix, + idSeparator, + useFallbackUiForUnsupportedType, + ...(experimental_componentUpdateStrategy !== undefined && { experimental_componentUpdateStrategy }), + ...(nameGenerator !== undefined && { nameGenerator }), + }; +} + +/** Computes the `Registry` for the form by merging the default registry entries with any overrides + * supplied in `props`. + * + * @param props - The form props containing optional `fields`, `widgets`, `templates`, `formContext`, and + * `translateString` overrides + * @param schema - The resolved root JSON Schema that will be set as `rootSchema` on the registry + * @param schemaUtils - The `SchemaUtilsType` instance to attach to the registry + * @returns - A fully-merged `Registry` ready for use by fields, widgets, and templates + */ +export function buildRegistry( + props: FormProps, + schema: S, + schemaUtils: SchemaUtilsType, +): Registry { + const { translateString: customTranslateString, uiSchema = {} } = props; + const { fields, templates, widgets, formContext, translateString } = getDefaultRegistry(); + return { + fields: { ...fields, ...props.fields }, + templates: { + ...templates, + ...props.templates, + ButtonTemplates: { + ...templates.ButtonTemplates, + ...props.templates?.ButtonTemplates, + }, + } as TemplatesType, + widgets: { ...widgets, ...props.widgets }, + rootSchema: schema, + formContext: props.formContext || formContext, + schemaUtils, + translateString: customTranslateString || translateString, + globalUiOptions: uiSchema[UI_GLOBAL_OPTIONS_KEY], + globalFormOptions: getGlobalFormOptions(props), + uiSchemaDefinitions: uiSchema[UI_DEFINITIONS_KEY] ?? {}, + }; +} + +/** Merges any `extraErrors` or `customErrors` into the given `schemaValidation` object. + * + * @param schemaValidation - The base validation result (errors + errorSchema) from JSON Schema validation + * @param extraErrors - Optional additional errors provided via the `extraErrors` prop; merged with + * `validationDataMerge` when present + * @param customErrors - Optional custom errors accumulated during `onChange` via an `ErrorSchemaBuilder`; + * merged with `preventDuplicates` semantics when present + * @returns - A new `ValidationData` object containing the combined errors and errorSchema + */ +export function mergeErrors( + schemaValidation: ValidationData, + extraErrors?: ErrorSchema, + customErrors?: ErrorSchemaBuilder, +): ValidationData { + let { errorSchema, errors } = schemaValidation; + if (extraErrors) { + const merged = validationDataMerge(schemaValidation, extraErrors); + errorSchema = merged.errorSchema; + errors = merged.errors; + } + if (customErrors) { + const merged = validationDataMerge({ errors, errorSchema }, customErrors.ErrorSchema, true); + errorSchema = merged.errorSchema; + errors = merged.errors; + } + return { errors, errorSchema }; +} + +/** Validates `formData` against the schema, returning the raw validation result. + * + * @param formData - The form data to validate + * @param schema - The JSON Schema to validate against + * @param schemaUtils - The `SchemaUtilsType` instance used to retrieve the resolved schema and run the + * validator + * @param customValidate - Optional custom validation function applied after JSON Schema validation + * @param transformErrors - Optional function to transform or filter the raw validation errors + * @param uiSchema - Optional UI schema passed through to the validator + * @param retrievedSchema - Optional pre-resolved schema; when provided, skips the `retrieveSchema` + * call to preserve AJV cache hits + * @returns - The raw `ValidationData` (errors + errorSchema) produced by the validator + */ +export function runValidation( + formData: T | undefined, + schema: S, + schemaUtils: SchemaUtilsType, + customValidate?: CustomValidator, + transformErrors?: ErrorTransformer, + uiSchema?: UiSchema, + retrievedSchema?: S, +): ValidationData { + const resolvedSchema = retrievedSchema ?? schemaUtils.retrieveSchema(schema, formData); + return schemaUtils + .getValidator() + .validateFormData(formData, resolvedSchema, customValidate, transformErrors, uiSchema); +} + +/** Performs live validation against the current `formData` and merges in `extraErrors` / `customErrors`. + * + * @param rootSchema - The resolved root JSON Schema to validate against + * @param schemaUtils - The `SchemaUtilsType` instance used to run validation + * @param originalErrorSchema - The pre-existing `ErrorSchema` on the form; optionally merged into the + * result when `mergeIntoOriginalErrorSchema` is true + * @param formData - The form data to validate + * @param extraErrors - Optional additional errors from the `extraErrors` prop; merged into the result + * @param customErrors - Optional custom errors accumulated via `onChange`; merged into the result + * @param retrievedSchema - Optional pre-resolved schema to avoid redundant `retrieveSchema` calls + * @param mergeIntoOriginalErrorSchema - When `true`, merges `originalErrorSchema` into the schema + * validation result using `preventDuplicates` semantics (used when `retrievedSchema` was + * provided, indicating the schema has not changed since the last retrieve) + * @param customValidate - Optional custom validation function applied after JSON Schema validation + * @param transformErrors - Optional function to transform or filter the raw validation errors + * @param uiSchema - Optional UI schema passed through to the validator + * @returns - An object containing the merged `errors`, `errorSchema`, and the raw + * `schemaValidationErrors` / `schemaValidationErrorSchema` before extra/custom error merging + */ +export function performLiveValidate( + rootSchema: S, + schemaUtils: SchemaUtilsType, + originalErrorSchema: ErrorSchema, + formData: T | undefined, + extraErrors: ErrorSchema | undefined, + customErrors: ErrorSchemaBuilder | undefined, + retrievedSchema: S | undefined, + mergeIntoOriginalErrorSchema: boolean, + customValidate: CustomValidator | undefined, + transformErrors: ErrorTransformer | undefined, + uiSchema: UiSchema | undefined, +): { + errors: RJSFValidationError[]; + errorSchema: ErrorSchema; + schemaValidationErrors: RJSFValidationError[]; + schemaValidationErrorSchema: ErrorSchema; +} { + const schemaValidation = runValidation( + formData, + rootSchema, + schemaUtils, + customValidate, + transformErrors, + uiSchema, + retrievedSchema, + ); + const { errors } = schemaValidation; + let { errorSchema } = schemaValidation; + if (mergeIntoOriginalErrorSchema) { + errorSchema = mergeObjects( + originalErrorSchema, + schemaValidation.errorSchema, + 'preventDuplicates', + ) as ErrorSchema; + } + const schemaValidationErrors = errors; + const schemaValidationErrorSchema = errorSchema; + const mergedErrors = mergeErrors({ errorSchema, errors }, extraErrors, customErrors); + return { ...mergedErrors, schemaValidationErrors, schemaValidationErrorSchema }; +} + +/** Returns the same `retrievedSchema` reference when its content hasn't changed, preserving AJV cache + * hits. AJV caches compiled validators by schema reference identity, so reusing the same object + * avoids redundant recompilation on every render. + * + * @param next - The freshly retrieved schema + * @param current - The previously stored schema reference, or `undefined` on first call + * @returns - `current` when it is deeply equal to `next`; otherwise `next` + */ +export function stableRetrievedSchema(next: S, current: S | undefined): S { + return deepEquals(next, current) ? (current as S) : next; +} + +/** Computes a complete `FormState` from the given `props` and the current state. Used both for the + * initial state (in the `useReducer` initializer) and whenever relevant props change (in the + * `componentDidUpdate` equivalent effect). + * + * @param props - The current form props + * @param currentState - A partial snapshot of the existing state; used to preserve `schemaUtils`, + * cached errors, and other values that should only change when their inputs change + * @param inputFormData - The form data to use as the basis for default-filling and validation; + * pass `IS_RESET` to clear all data back to schema defaults + * @param retrievedSchema - Optional pre-resolved schema; when provided, skips `retrieveSchema` and + * opts into merging `originalErrorSchema` (since the schema hasn't changed) + * @param isSchemaChanged - When `true`, existing validation errors are cleared because they no longer + * apply to the new schema + * @param formDataChangedFields - List of top-level field paths that changed; errors for those fields + * are cleared when live validation is not active + * @param skipLiveValidate - When `true`, live validation is skipped even if `liveValidate` is set; + * used to avoid a redundant validation pass when the state data hasn't changed + * @returns - A fully-computed `FormState` ready to be committed via `dispatch` + */ +export function getStateFromProps( + props: FormProps, + currentState: Partial>, + inputFormData?: T, + retrievedSchema?: S, + isSchemaChanged = false, + formDataChangedFields: string[] = [], + skipLiveValidate = false, +): FormState { + const { schema, validator, uiSchema: rawUiSchema, noValidate, liveValidate, extraErrors } = props; + const uiSchema: UiSchema = rawUiSchema || {}; + const isUncontrolled = props.formData === undefined; + const edit = typeof inputFormData !== 'undefined'; + const mustValidate = edit && !noValidate && liveValidate; + + let schemaUtils: SchemaUtilsType = currentState.schemaUtils!; + if ( + !schemaUtils || + schemaUtils.doesSchemaUtilsDiffer( + validator, + schema, + props.experimental_defaultFormStateBehavior, + props.experimental_customMergeAllOf, + ) + ) { + schemaUtils = createSchemaUtils( + validator, + schema, + props.experimental_defaultFormStateBehavior, + props.experimental_customMergeAllOf, + ); + } + + const rootSchema = schemaUtils.getRootSchema(); + + let defaultsFormData = inputFormData; + if (inputFormData === IS_RESET) { + defaultsFormData = undefined; + } else if (inputFormData === undefined && isUncontrolled) { + defaultsFormData = currentState.formData; + } + + const formData: T = schemaUtils.getDefaultFormState( + rootSchema, + defaultsFormData, + false, + currentState.initialDefaultsGenerated, + ) as T; + + const rawRetrievedSchema = retrievedSchema ?? schemaUtils.retrieveSchema(rootSchema, formData); + const _retrievedSchema = stableRetrievedSchema(rawRetrievedSchema, currentState.retrievedSchema); + + const getCurrentErrors = (): ValidationData => { + if (noValidate || isSchemaChanged) { + return { errors: [], errorSchema: {} }; + } else if (!liveValidate) { + return { + errors: currentState.schemaValidationErrors || [], + errorSchema: currentState.schemaValidationErrorSchema || {}, + }; + } + return { + errors: currentState.errors || [], + errorSchema: currentState.errorSchema || {}, + }; + }; + + let errors: RJSFValidationError[]; + let errorSchema: ErrorSchema | undefined; + let schemaValidationErrors: RJSFValidationError[] = currentState.schemaValidationErrors || []; + let schemaValidationErrorSchema: ErrorSchema = currentState.schemaValidationErrorSchema || {}; + + if (mustValidate && !skipLiveValidate) { + const liveValidation = performLiveValidate( + rootSchema, + schemaUtils, + currentState.errorSchema as ErrorSchema, + formData, + undefined, + currentState.customErrors, + retrievedSchema, + retrievedSchema !== undefined, + props.customValidate, + props.transformErrors, + uiSchema, + ); + errors = liveValidation.errors; + errorSchema = liveValidation.errorSchema; + schemaValidationErrors = liveValidation.schemaValidationErrors; + schemaValidationErrorSchema = liveValidation.schemaValidationErrorSchema; + } else { + const currentErrors = getCurrentErrors(); + errors = currentErrors.errors; + errorSchema = currentErrors.errorSchema; + if (formDataChangedFields.length > 0 && !mustValidate) { + const clearedFields = formDataChangedFields.reduce( + (acc, key) => { + acc[key] = undefined; + return acc; + }, + {} as Record, + ); + errorSchema = schemaValidationErrorSchema = mergeObjects( + currentErrors.errorSchema, + clearedFields, + 'preventDuplicates', + ) as ErrorSchema; + } + const mergedErrors = mergeErrors({ errorSchema, errors }, extraErrors, currentState.customErrors); + errors = mergedErrors.errors; + errorSchema = mergedErrors.errorSchema; + } + + const newRegistry = buildRegistry(props, rootSchema, schemaUtils); + const registry = deepEquals(currentState.registry, newRegistry) ? currentState.registry! : newRegistry; + + const fieldPathId: FieldPathId = + currentState.fieldPathId && currentState.fieldPathId?.[ID_KEY] === registry.globalFormOptions.idPrefix + ? currentState.fieldPathId + : toFieldPathId('', registry.globalFormOptions); + + return { + schemaUtils, + schema: rootSchema, + uiSchema, + fieldPathId, + formData, + edit, + errors, + errorSchema: errorSchema!, + schemaValidationErrors, + schemaValidationErrorSchema, + retrievedSchema: _retrievedSchema, + initialDefaultsGenerated: true, + registry, + }; +} + +// ─── Reducer ───────────────────────────────────────────────────────────────── + +/** Updates the schema/UI slice: schema, uiSchema, schemaUtils, registry, fieldPathId */ +export type SetSchemaAction = { + type: 'SET_SCHEMA'; + payload: Partial, 'schema' | 'uiSchema' | 'schemaUtils' | 'registry' | 'fieldPathId'>>; +}; + +/** Updates the form-data slice: formData, edit, retrievedSchema, initialDefaultsGenerated */ +export type SetFormDataAction = { + type: 'SET_FORM_DATA'; + payload: Partial, 'formData' | 'edit' | 'retrievedSchema' | 'initialDefaultsGenerated'>>; +}; + +/** Updates the errors slice: errors, errorSchema, schemaValidation*, customErrors, _prevExtraErrors */ +export type SetErrorsAction = { + type: 'SET_ERRORS'; + payload: Partial< + Pick< + FormState, + | 'errors' + | 'errorSchema' + | 'schemaValidationErrors' + | 'schemaValidationErrorSchema' + | 'customErrors' + | '_prevExtraErrors' + > + >; +}; + +/** Catch-all for cross-group updates (full-state replacements, complex mixed updates) */ +export type SetStateAction = { + type: 'SET_STATE'; + payload: Partial>; +}; + +export type FormAction = + | SetSchemaAction + | SetFormDataAction + | SetErrorsAction + | SetStateAction; + +/** Pure reducer for the form's `useReducer` hook. Spreads the action's `payload` over the current + * state; the action type exists solely for call-site clarity and has no effect on the merge logic. + * + * @param state - The current `FormState` + * @param action - A discriminated union action whose `payload` is a partial `FormState` slice + * @returns A new `FormState` with the payload merged in + */ +export function formReducer( + state: FormState, + action: FormAction, +): FormState { + return { ...state, ...action.payload }; +} + +// ─── Memo comparison ───────────────────────────────────────────────────────── + +/** Memo comparison function passed to `React.memo` that respects the + * `experimental_componentUpdateStrategy` prop. + * + * - `'always'` — always returns `false` (component always re-renders) + * - `'shallow'` — returns `true` only when every prop is `Object.is`-equal (same as `PureComponent`) + * - `'customDeep'` (default) — uses `deepEquals`, which treats all functions as equivalent, + * preventing unnecessary re-renders when callback props are re-created on each parent render + * + * @param prev - The previous render's props + * @param next - The next render's props + * @returns - `true` when the component should skip re-rendering; `false` when it should re-render + */ +export function propsAreEqual( + prev: Readonly, 'ref'>>, + next: Readonly, 'ref'>>, +): boolean { + const strategy = next.experimental_componentUpdateStrategy ?? 'customDeep'; + if (strategy === 'always') { + return false; + } + if (strategy === 'shallow') { + const keys = [...new Set([...Object.keys(prev), ...Object.keys(next)])] as Array; + return keys.every((k) => Object.is(prev[k], next[k])); + } + // 'customDeep' — treats functions as equivalent + return deepEquals(prev, next); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f6bd14ab7..2767899b92 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -import Form, { FormProps, FormState, IChangeEvent } from './components/Form'; +import Form, { FormRef, FormProps, FormState, IChangeEvent } from './components/Form'; import RichDescription, { RichDescriptionProps } from './components/RichDescription'; import RichHelp, { RichHelpProps } from './components/RichHelp'; import SchemaExamples, { SchemaExamplesProps } from './components/SchemaExamples'; @@ -7,6 +7,11 @@ import getDefaultRegistry from './getDefaultRegistry'; import getTestRegistry from './getTestRegistry'; export type { + /** Backward-compatible type alias for `FormRef`. Consumers who previously used the class-based + * `Form` as a ref type (e.g. `createRef()`) can continue to do so via this alias. + */ + FormRef as Form, + FormRef, FormProps, FormState, IChangeEvent, diff --git a/packages/core/src/withTheme.tsx b/packages/core/src/withTheme.tsx index d1bf540c78..3d97184a90 100644 --- a/packages/core/src/withTheme.tsx +++ b/packages/core/src/withTheme.tsx @@ -1,7 +1,8 @@ -import { ComponentType, ForwardedRef, forwardRef } from 'react'; -import Form, { FormProps } from './components/Form'; +import { ComponentType, forwardRef } from 'react'; import { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; +import Form, { FormRef, FormProps } from './components/Form'; + /** The properties for the `withTheme` function, essentially a subset of properties from the `FormProps` that can be * overridden while creating a theme */ @@ -14,9 +15,8 @@ export type ThemeProps( themeProps: ThemeProps, ): ComponentType> { - // @ts-expect-error TS2322 because the latest types complain about LegacyRef's string form not working with Form - return forwardRef, FormProps>( - ({ fields, widgets, templates, ...directProps }: FormProps, ref: ForwardedRef>) => { + return forwardRef, FormProps>( + ({ fields, widgets, templates, ...directProps }: FormProps, ref) => { fields = { ...themeProps?.fields, ...fields }; widgets = { ...themeProps?.widgets, ...widgets }; templates = { @@ -39,5 +39,5 @@ export default function withTheme ); }, - ); + ) as ComponentType>; } diff --git a/packages/core/test/Form.test.tsx b/packages/core/test/Form.test.tsx index 759ad5db17..867925517c 100644 --- a/packages/core/test/Form.test.tsx +++ b/packages/core/test/Form.test.tsx @@ -1,5 +1,5 @@ -import { Component, RefObject, createRef, useEffect, useState, useCallback } from 'react'; -import { fireEvent, act, render, waitFor } from '@testing-library/react'; +import { Component, RefObject, createRef, useEffect, useState, useCallback, useRef } from 'react'; +import { fireEvent, act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Portal } from 'react-portal'; import { @@ -21,7 +21,7 @@ import { } from '@rjsf/utils'; import validator, { customizeValidator } from '@rjsf/validator-ajv8'; -import Form, { FormProps, IChangeEvent } from '../src'; +import Form, { FormProps, FormRef, IChangeEvent } from '../src'; import { createComponent, createFormComponent, @@ -3978,7 +3978,7 @@ describe('Form omitExtraData and liveOmit', () => { }; const omitExtraData = true; const liveOmit = true; - const ref = createRef(); + const ref = createRef(); const { node } = createFormComponent({ ref, @@ -4010,7 +4010,7 @@ describe('Form omitExtraData and liveOmit', () => { foo: 'bar', }; const omitExtraData = true; - const ref = createRef(); + const ref = createRef(); const { node } = createFormComponent({ ref, schema, @@ -4325,7 +4325,7 @@ describe('Form omitExtraData and liveOmit', () => { const onSubmit = jest.fn(); - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4366,7 +4366,7 @@ describe('omitExtraData on submit', () => { foo: '', }; const omitExtraData = true; - const ref = createRef(); + const ref = createRef(); const { node } = createFormComponent({ ref, schema, @@ -4390,7 +4390,7 @@ describe('omitExtraData on submit', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4412,7 +4412,7 @@ describe('omitExtraData on submit', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4490,7 +4490,7 @@ describe('Async errors', () => { }, } as unknown as ErrorSchema; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4525,7 +4525,7 @@ describe('Async errors', () => { }, } as unknown as ErrorSchema; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4554,7 +4554,7 @@ describe('Async errors', () => { required: ['foo'], }; - const formRef = createRef(); + const formRef = createRef(); const { rerender, node } = createFormComponent({ ref: formRef, schema, @@ -4610,7 +4610,7 @@ describe('Async errors', () => { }, }; - const formRef = createRef(); + const formRef = createRef(); function Wrapper() { const [formData, setFormData] = useState>({ values: [] }); @@ -4734,7 +4734,7 @@ describe('Calling reset from ref object', () => { title: 'Test form', type: 'string', }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4754,7 +4754,7 @@ describe('Calling reset from ref object', () => { title: 'Test form', type: 'number', }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4783,7 +4783,7 @@ describe('Calling reset from ref object', () => { type: 'string', default: 'Some-Value', }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema: schemaWithDefault, @@ -4806,7 +4806,7 @@ describe('Calling reset from ref object', () => { it('Reset button test with complex schema', () => { const schema = widgetsSchema as RJSFSchema; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4840,7 +4840,7 @@ describe('Calling reset from ref object', () => { title: 'Test form', type: 'string', }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, initialFormData: 'foo', @@ -4870,7 +4870,7 @@ describe('validateForm()', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4894,7 +4894,7 @@ describe('validateForm()', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = createRef(); + const formRef = createRef(); const props: NoValFormProps = { ref: formRef, schema, @@ -4910,7 +4910,7 @@ describe('validateForm()', () => { }); it('Should update state when data updated from invalid to valid', () => { - const ref = createRef(); + const ref = createRef(); const props: NoValFormProps = { schema: { type: 'object', @@ -4955,7 +4955,7 @@ describe('validateForm()', () => { }); it('Should keep non-blocking extraErrors in state when schema is valid and extraErrorsBlockSubmit is not set', () => { - const formRef = createRef(); + const formRef = createRef(); const schema: RJSFSchema = { type: 'object', properties: { @@ -4991,7 +4991,7 @@ describe('validateForm()', () => { }); it('Should return false and call onError when extraErrors are present with extraErrorsBlockSubmit set', () => { - const formRef = createRef(); + const formRef = createRef(); const schema: RJSFSchema = { type: 'object', properties: { @@ -5029,7 +5029,7 @@ describe('validateForm()', () => { }); it('Should show both schema and extraErrors in state when schema is invalid regardless of extraErrorsBlockSubmit', () => { - const formRef = createRef(); + const formRef = createRef(); const schema: RJSFSchema = { type: 'object', required: ['foo'], @@ -5065,7 +5065,7 @@ describe('validateForm()', () => { }); it('Should clear extraErrors from state when extraErrors prop is removed and validateForm is called again', () => { - const formRef = createRef(); + const formRef = createRef(); const schema: RJSFSchema = { type: 'object', properties: { @@ -5105,7 +5105,7 @@ describe('validateForm()', () => { describe('setFieldValue()', () => { it('Sets root to value using ""', () => { - const ref = createRef(); + const ref = createRef(); const props: NoValFormProps = { schema: { type: 'string', @@ -5129,7 +5129,7 @@ describe('setFieldValue()', () => { expect(node.querySelector('input')).toHaveAttribute('value', 'populated value'); }); it('Sets root to value using []', () => { - const ref = createRef(); + const ref = createRef(); const props: NoValFormProps = { schema: { type: 'string', @@ -5153,7 +5153,7 @@ describe('setFieldValue()', () => { expect(node.querySelector('input')).toHaveAttribute('value', 'populated value'); }); it('Sets field to new value via dotted path', () => { - const ref = createRef(); + const ref = createRef(); const props: NoValFormProps = { schema: { type: 'object', @@ -5207,7 +5207,7 @@ describe('setFieldValue()', () => { expect(errors).toHaveLength(0); }); it('Sets field to new value via field path list', () => { - const ref = createRef(); + const ref = createRef(); const props: NoValFormProps = { schema: { type: 'object', @@ -5967,7 +5967,7 @@ describe('extraErrors set after submit (#4965)', () => { foo: { __errors: ['Server-side error'] }, } as unknown as ErrorSchema; - const formRef = createRef(); + const formRef = createRef(); function Wrapper() { const [extraErrors, setExtraErrors] = useState({} as ErrorSchema); @@ -6002,3 +6002,90 @@ describe('extraErrors set after submit (#4965)', () => { expect(errorItems.length).toBeGreaterThan(0); }); }); + +describe('validation after changing schema (#5034)', () => { + const schemaA: RJSFSchema = { type: 'string', minLength: 10, maxLength: 15 }; + + const schemaB: RJSFSchema = { type: 'string', minLength: 20 }; + + const valueA = 'invalid'; + + const valueB = 'this is also invalid'; + + function TestComponent() { + const [schema, setSchema] = useState(schemaA); + const [formData, setFormData] = useState(valueA); + + const handleSchemaClick = useCallback(() => { + setSchema((prev) => (prev === schemaA ? schemaB : schemaA)); + }, []); + const handleDataClick = useCallback(() => { + setFormData((prev) => (prev === valueA ? valueB : valueA)); + }, []); + const ref = useRef(null); + + useEffect(() => { + ref.current?.validateForm(); + }, [schema, formData]); + + return ( +
+ + + +

+ Schema: {JSON.stringify(schema)} +

+

+ Data: {formData} +

+
+ ); + } + it('initially has schemaA', () => { + render(); + const component = screen.getByTestId('component'); + expect(component).toBeInTheDocument(); + + const outputSchema = within(component).getByTestId('output-schema'); + expect(outputSchema).toHaveTextContent(JSON.stringify(schemaA)); + const outputData = within(component).getByTestId('output-data'); + expect(outputData).toHaveTextContent(valueA); + const errors = component.querySelector('ul[id=root__error]'); + expect(errors).toHaveTextContent('must NOT have fewer than 10 characters'); + }); + it('switches to schemaB when button is pushed', async () => { + render(); + const component = screen.getByTestId('component'); + expect(component).toBeInTheDocument(); + const button = within(component).getByTestId('schema-button'); + expect(button).toHaveTextContent('Toggle Schema'); + await user.click(button); + + const outputSchema = within(component).getByTestId('output-schema'); + expect(outputSchema).toHaveTextContent(JSON.stringify(schemaB)); + const outputData = within(component).getByTestId('output-data'); + expect(outputData).toHaveTextContent(valueA); + const errors = component.querySelector('ul[id=root__error]'); + expect(errors).toHaveTextContent('must NOT have fewer than 20 characters'); + }); + it('switches to valueB when button is pushed', async () => { + render(); + const component = screen.getByTestId('component'); + expect(component).toBeInTheDocument(); + const button = within(component).getByTestId('data-button'); + expect(button).toHaveTextContent('Toggle Data'); + await user.click(button); + + const outputSchema = within(component).getByTestId('output-schema'); + expect(outputSchema).toHaveTextContent(JSON.stringify(schemaA)); + const outputData = within(component).getByTestId('output-data'); + expect(outputData).toHaveTextContent(valueB); + const errors = component.querySelector('ul[id=root__error]'); + expect(errors).toHaveTextContent('must NOT have more than 15 characters'); + }); +}); diff --git a/packages/core/test/NumberField.test.tsx b/packages/core/test/NumberField.test.tsx index f8b2083ec0..2202f68e1a 100644 --- a/packages/core/test/NumberField.test.tsx +++ b/packages/core/test/NumberField.test.tsx @@ -4,7 +4,7 @@ import { act, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import isEmpty from 'lodash/isEmpty'; -import Form from '../src'; +import type { Form } from '../src'; import { createFormComponent, expectToHaveBeenCalledWithFormData, diff --git a/packages/core/test/formUtils.test.ts b/packages/core/test/formUtils.test.ts new file mode 100644 index 0000000000..0f0eb5b2d8 --- /dev/null +++ b/packages/core/test/formUtils.test.ts @@ -0,0 +1,656 @@ +import { + createSchemaUtils, + ErrorSchemaBuilder, + RJSFSchema, + toErrorList, + ValidationData, + DEFAULT_ID_PREFIX, + DEFAULT_ID_SEPARATOR, + ID_KEY, +} from '@rjsf/utils'; +import validator from '@rjsf/validator-ajv8'; + +import getDefaultRegistry from '../src/getDefaultRegistry'; +import { + buildRegistry, + formReducer, + getGlobalFormOptions, + getStateFromProps, + mergeErrors, + performLiveValidate, + propsAreEqual, + runValidation, + stableRetrievedSchema, + toIChangeEvent, +} from '../src/components/formUtils'; + +import type { FormProps, FormState } from '../src/components/Form'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const SIMPLE_SCHEMA: RJSFSchema = { type: 'string' }; +const STRING_MIN_SCHEMA: RJSFSchema = { type: 'string', minLength: 3 }; +const OBJECT_SCHEMA: RJSFSchema = { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, +}; + +function makeProps(overrides: Partial = {}): FormProps { + return { schema: SIMPLE_SCHEMA, validator, ...overrides }; +} + +function makeSchemaUtils(schema: RJSFSchema = SIMPLE_SCHEMA) { + return createSchemaUtils(validator, schema); +} + +// ─── stableRetrievedSchema ──────────────────────────────────────────────────── + +describe('stableRetrievedSchema', () => { + it('returns the current reference when content is deeply equal', () => { + const current = { type: 'string' } as RJSFSchema; + const next = { type: 'string' } as RJSFSchema; + const result = stableRetrievedSchema(next, current); + expect(result).toBe(current); + }); + + it('returns next when content differs', () => { + const current = { type: 'string' } as RJSFSchema; + const next = { type: 'number' } as RJSFSchema; + const result = stableRetrievedSchema(next, current); + expect(result).toBe(next); + }); + + it('returns next when current is undefined', () => { + const next = { type: 'string' } as RJSFSchema; + const result = stableRetrievedSchema(next, undefined); + expect(result).toBe(next); + }); +}); + +// ─── toIChangeEvent ─────────────────────────────────────────────────────────── + +describe('toIChangeEvent', () => { + const schemaUtils = makeSchemaUtils(); + const baseState: FormState = { + schema: SIMPLE_SCHEMA, + uiSchema: {}, + fieldPathId: { [ID_KEY]: 'root', path: ['root'] }, + schemaUtils, + formData: 'hello', + edit: true, + errors: [], + errorSchema: {}, + schemaValidationErrors: [], + schemaValidationErrorSchema: {}, + retrievedSchema: SIMPLE_SCHEMA, + initialDefaultsGenerated: true, + registry: { ...getDefaultRegistry(), schemaUtils }, + }; + + it('picks only the public IChangeEvent fields', () => { + const event = toIChangeEvent(baseState); + expect(event).toHaveProperty('schema', SIMPLE_SCHEMA); + expect(event).toHaveProperty('uiSchema', {}); + expect(event).toHaveProperty('formData', 'hello'); + expect(event).toHaveProperty('edit', true); + expect(event).toHaveProperty('errors', []); + expect(event).toHaveProperty('errorSchema', {}); + // Private fields should NOT be present + expect(event).not.toHaveProperty('schemaValidationErrors'); + expect(event).not.toHaveProperty('schemaValidationErrorSchema'); + expect(event).not.toHaveProperty('retrievedSchema'); + expect(event).not.toHaveProperty('initialDefaultsGenerated'); + expect(event).not.toHaveProperty('registry'); + expect(event).not.toHaveProperty('_prevExtraErrors'); + }); + + it('omits status when not provided', () => { + const event = toIChangeEvent(baseState); + expect(event).not.toHaveProperty('status'); + }); + + it('includes status when provided', () => { + const event = toIChangeEvent(baseState, 'submitted'); + expect(event).toHaveProperty('status', 'submitted'); + }); +}); + +// ─── getGlobalFormOptions ───────────────────────────────────────────────────── + +describe('getGlobalFormOptions', () => { + it('returns defaults when no relevant props are set', () => { + const opts = getGlobalFormOptions(makeProps()); + expect(opts.idPrefix).toBe(DEFAULT_ID_PREFIX); + expect(opts.idSeparator).toBe(DEFAULT_ID_SEPARATOR); + expect(opts.useFallbackUiForUnsupportedType).toBe(false); + expect(opts.experimental_componentUpdateStrategy).toBeUndefined(); + expect(opts.nameGenerator).toBeUndefined(); + }); + + it('uses ui:rootFieldId as idPrefix over idPrefix prop', () => { + const opts = getGlobalFormOptions( + makeProps({ uiSchema: { 'ui:rootFieldId': 'myRoot' }, idPrefix: 'should-be-ignored' }), + ); + expect(opts.idPrefix).toBe('myRoot'); + }); + + it('uses idPrefix prop when no ui:rootFieldId', () => { + const opts = getGlobalFormOptions(makeProps({ idPrefix: 'myPrefix' })); + expect(opts.idPrefix).toBe('myPrefix'); + }); + + it('uses custom idSeparator', () => { + const opts = getGlobalFormOptions(makeProps({ idSeparator: '.' })); + expect(opts.idSeparator).toBe('.'); + }); + + it('includes experimental_componentUpdateStrategy when set', () => { + const opts = getGlobalFormOptions(makeProps({ experimental_componentUpdateStrategy: 'shallow' })); + expect(opts.experimental_componentUpdateStrategy).toBe('shallow'); + }); + + it('includes nameGenerator when provided', () => { + const ng = jest.fn(); + const opts = getGlobalFormOptions(makeProps({ nameGenerator: ng })); + expect(opts.nameGenerator).toBe(ng); + }); + + it('sets useFallbackUiForUnsupportedType correctly', () => { + const opts = getGlobalFormOptions(makeProps({ useFallbackUiForUnsupportedType: true })); + expect(opts.useFallbackUiForUnsupportedType).toBe(true); + }); +}); + +// ─── buildRegistry ──────────────────────────────────────────────────────────── + +describe('buildRegistry', () => { + const schemaUtils = makeSchemaUtils(); + + it('builds a registry with default fields/widgets/templates', () => { + const registry = buildRegistry(makeProps(), SIMPLE_SCHEMA, schemaUtils); + const defaults = getDefaultRegistry(); + expect(Object.keys(registry.fields)).toEqual(expect.arrayContaining(Object.keys(defaults.fields))); + expect(Object.keys(registry.widgets)).toEqual(expect.arrayContaining(Object.keys(defaults.widgets))); + expect(registry.rootSchema).toBe(SIMPLE_SCHEMA); + expect(registry.schemaUtils).toBe(schemaUtils); + }); + + it('merges custom fields over defaults', () => { + const CustomField = jest.fn(); + const registry = buildRegistry(makeProps({ fields: { CustomField } }), SIMPLE_SCHEMA, schemaUtils); + expect(registry.fields.CustomField).toBe(CustomField); + }); + + it('merges custom widgets over defaults', () => { + const MyWidget = jest.fn(); + const registry = buildRegistry(makeProps({ widgets: { MyWidget } }), SIMPLE_SCHEMA, schemaUtils); + expect(registry.widgets.MyWidget).toBe(MyWidget); + }); + + it('merges ButtonTemplates deeply', () => { + const MySubmitButton = jest.fn(); + const registry = buildRegistry( + makeProps({ templates: { ButtonTemplates: { SubmitButton: MySubmitButton } } }), + SIMPLE_SCHEMA, + schemaUtils, + ); + expect(registry.templates.ButtonTemplates.SubmitButton).toBe(MySubmitButton); + // Other ButtonTemplates from defaults should still be present + expect(registry.templates.ButtonTemplates.AddButton).toBeDefined(); + }); + + it('uses custom translateString when provided', () => { + const customTranslate = jest.fn(); + const registry = buildRegistry(makeProps({ translateString: customTranslate }), SIMPLE_SCHEMA, schemaUtils); + expect(registry.translateString).toBe(customTranslate); + }); + + it('falls back to default translateString when not provided', () => { + const registry = buildRegistry(makeProps(), SIMPLE_SCHEMA, schemaUtils); + expect(registry.translateString).toBeDefined(); + expect(registry.translateString).toBe(getDefaultRegistry().translateString); + }); + + it('uses custom formContext', () => { + const formContext = { myKey: 'myValue' }; + const registry = buildRegistry(makeProps({ formContext }), SIMPLE_SCHEMA, schemaUtils); + expect(registry.formContext).toBe(formContext); + }); +}); + +// ─── mergeErrors ───────────────────────────────────────────────────────────── + +describe('mergeErrors', () => { + const baseValidation: ValidationData = { + errors: [{ name: 'type', property: '.name', message: 'wrong type', stack: '.name wrong type', params: {} }], + errorSchema: new ErrorSchemaBuilder().addErrors(['wrong type'], 'name').ErrorSchema, + }; + + it('returns unchanged when no extraErrors or customErrors', () => { + const result = mergeErrors(baseValidation); + expect(result.errors).toEqual(baseValidation.errors); + expect(result.errorSchema).toEqual(baseValidation.errorSchema); + }); + + it('merges extraErrors into the result', () => { + const extraErrors = new ErrorSchemaBuilder().addErrors(['extra error'], 'name').ErrorSchema; + const result = mergeErrors(baseValidation, extraErrors); + expect(result.errors.length).toBeGreaterThan(baseValidation.errors.length); + expect(result.errorSchema.name?.__errors).toContain('extra error'); + }); + + it('merges customErrors (ErrorSchemaBuilder) into the result', () => { + const customErrors = new ErrorSchemaBuilder().addErrors(['custom error'], 'age'); + const result = mergeErrors({ errors: [], errorSchema: {} }, undefined, customErrors); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errorSchema.age?.__errors).toContain('custom error'); + }); + + it('merges both extraErrors and customErrors', () => { + const extraErrors = new ErrorSchemaBuilder().addErrors(['extra'], 'name').ErrorSchema; + const customErrors = new ErrorSchemaBuilder().addErrors(['custom'], 'age'); + const result = mergeErrors({ errors: [], errorSchema: {} }, extraErrors, customErrors); + expect(result.errorSchema.name?.__errors).toContain('extra'); + expect(result.errorSchema.age?.__errors).toContain('custom'); + }); +}); + +// ─── runValidation ──────────────────────────────────────────────────────────── + +describe('runValidation', () => { + it('returns no errors for valid data', () => { + const schemaUtils = makeSchemaUtils(STRING_MIN_SCHEMA); + const result = runValidation('hello world', STRING_MIN_SCHEMA, schemaUtils); + expect(result.errors).toHaveLength(0); + expect(result.errorSchema).toEqual({}); + }); + + it('returns errors for invalid data', () => { + const schemaUtils = makeSchemaUtils(STRING_MIN_SCHEMA); + const result = runValidation('hi', STRING_MIN_SCHEMA, schemaUtils); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].message).toContain('3'); + }); + + it('uses provided retrievedSchema instead of recomputing', () => { + const schemaUtils = makeSchemaUtils(STRING_MIN_SCHEMA); + const retrieveSpy = jest.spyOn(schemaUtils, 'retrieveSchema'); + const retrieved = schemaUtils.retrieveSchema(STRING_MIN_SCHEMA, 'hi'); + retrieveSpy.mockClear(); + + runValidation('hi', STRING_MIN_SCHEMA, schemaUtils, undefined, undefined, undefined, retrieved); + expect(retrieveSpy).not.toHaveBeenCalled(); + }); + + it('calls retrieveSchema when no retrievedSchema is provided', () => { + const schemaUtils = makeSchemaUtils(STRING_MIN_SCHEMA); + const retrieveSpy = jest.spyOn(schemaUtils, 'retrieveSchema'); + + runValidation('hi', STRING_MIN_SCHEMA, schemaUtils); + expect(retrieveSpy).toHaveBeenCalledTimes(1); + }); + + it('applies a customValidate function', () => { + const schemaUtils = makeSchemaUtils(SIMPLE_SCHEMA); + const customValidate = jest.fn((formData: string | undefined, errors: any) => { + if (formData === 'bad') { + errors.addError('custom error'); + } + return errors; + }); + const result = runValidation('bad', SIMPLE_SCHEMA, schemaUtils, customValidate); + expect(result.errors.some((e) => e.message === 'custom error')).toBe(true); + }); +}); + +// ─── performLiveValidate ────────────────────────────────────────────────────── + +describe('performLiveValidate', () => { + const schemaUtils = makeSchemaUtils(STRING_MIN_SCHEMA); + + it('returns validation errors and schema-level errors', () => { + const result = performLiveValidate( + STRING_MIN_SCHEMA, + schemaUtils, + {}, + 'hi', + undefined, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + ); + expect(result.schemaValidationErrors.length).toBeGreaterThan(0); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('returns empty errors for valid data', () => { + const result = performLiveValidate( + STRING_MIN_SCHEMA, + schemaUtils, + {}, + 'hello world', + undefined, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + ); + expect(result.errors).toHaveLength(0); + expect(result.schemaValidationErrors).toHaveLength(0); + }); + + it('merges originalErrorSchema when mergeIntoOriginalErrorSchema is true', () => { + const originalErrorSchema = new ErrorSchemaBuilder().addErrors(['pre-existing error']).ErrorSchema; + const result = performLiveValidate( + STRING_MIN_SCHEMA, + schemaUtils, + originalErrorSchema, + 'hi', + undefined, + undefined, + undefined, + true, + undefined, + undefined, + undefined, + ); + expect(result.errorSchema.__errors).toContain('pre-existing error'); + }); + + it('does NOT merge originalErrorSchema when mergeIntoOriginalErrorSchema is false', () => { + const originalErrorSchema = new ErrorSchemaBuilder().addErrors(['pre-existing error']).ErrorSchema; + const result = performLiveValidate( + STRING_MIN_SCHEMA, + schemaUtils, + originalErrorSchema, + 'hi', + undefined, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + ); + expect(result.errorSchema.__errors ?? []).not.toContain('pre-existing error'); + }); + + it('merges extraErrors into the result', () => { + const extraErrors = new ErrorSchemaBuilder().addErrors(['extra']).ErrorSchema; + const result = performLiveValidate( + STRING_MIN_SCHEMA, + schemaUtils, + {}, + 'hi', + extraErrors, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + ); + expect(result.errorSchema.__errors).toContain('extra'); + }); + + it('schemaValidationErrors are independent of extraErrors', () => { + const extraErrors = new ErrorSchemaBuilder().addErrors(['extra']).ErrorSchema; + const result = performLiveValidate( + STRING_MIN_SCHEMA, + schemaUtils, + {}, + 'hi', + extraErrors, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + ); + // schemaValidationErrors should not include extra errors + expect(result.schemaValidationErrors.every((e) => e.message !== 'extra')).toBe(true); + }); +}); + +// ─── getStateFromProps ──────────────────────────────────────────────────────── + +describe('getStateFromProps', () => { + it('computes initial state with no formData', () => { + const state = getStateFromProps(makeProps(), {}); + expect(state.formData).toBeUndefined(); + expect(state.edit).toBe(false); + expect(state.errors).toEqual([]); + expect(state.schema).toEqual(SIMPLE_SCHEMA); + expect(state.initialDefaultsGenerated).toBe(true); + }); + + it('sets edit=true when formData is provided', () => { + const state = getStateFromProps(makeProps({ formData: 'hello' }), {}, 'hello'); + expect(state.edit).toBe(true); + expect(state.formData).toBe('hello'); + }); + + it('uses existing schemaUtils when validator and schema are unchanged', () => { + const schemaUtils = makeSchemaUtils(SIMPLE_SCHEMA); + const state = getStateFromProps(makeProps(), { schemaUtils }, 'hello'); + expect(state.schemaUtils).toBe(schemaUtils); + }); + + it('creates new schemaUtils when schema changes', () => { + const oldSchemaUtils = makeSchemaUtils(SIMPLE_SCHEMA); + const state = getStateFromProps(makeProps({ schema: STRING_MIN_SCHEMA }), { schemaUtils: oldSchemaUtils }, 'hi'); + expect(state.schemaUtils).not.toBe(oldSchemaUtils); + }); + + it('clears errors when isSchemaChanged=true and noValidate is set', () => { + const currentState: Partial = { + errors: [{ name: 'type', property: '.', message: 'old error', stack: '. old error', params: {} }], + errorSchema: new ErrorSchemaBuilder().addErrors(['old error']).ErrorSchema, + }; + const state = getStateFromProps(makeProps({ noValidate: true }), currentState, undefined, undefined, true); + expect(state.errors).toEqual([]); + expect(state.errorSchema).toEqual({}); + }); + + it('runs live validation when liveValidate=true and edit=true', () => { + const state = getStateFromProps( + makeProps({ schema: STRING_MIN_SCHEMA, liveValidate: true }), + {}, + 'hi', // violates minLength: 3 + ); + expect(state.errors.length).toBeGreaterThan(0); + }); + + it('skips live validation when skipLiveValidate=true', () => { + const state = getStateFromProps( + makeProps({ schema: STRING_MIN_SCHEMA, liveValidate: true }), + {}, + 'hi', + undefined, + false, + [], + true, // skipLiveValidate + ); + expect(state.errors).toEqual([]); + }); + + it('applies schema defaults to formData', () => { + const schemaWithDefault: RJSFSchema = { + type: 'object', + properties: { name: { type: 'string', default: 'Alice' } }, + }; + const state = getStateFromProps(makeProps({ schema: schemaWithDefault }), {}, {}); + expect(state.formData?.name).toBe('Alice'); + }); + + it('clears changed field errors when formDataChangedFields is provided', () => { + const nameRequiredSchema = new ErrorSchemaBuilder().addErrors(['required'], 'name').ErrorSchema; + const currentState: Partial = { + schemaValidationErrors: [], + schemaValidationErrorSchema: nameRequiredSchema, + errors: toErrorList(nameRequiredSchema), + errorSchema: nameRequiredSchema, + }; + const state = getStateFromProps( + makeProps({ schema: OBJECT_SCHEMA }), + currentState, + { name: 'Alice' }, + undefined, + false, + ['name'], + ); + expect(state.errorSchema.name?.__errors).toBeUndefined(); + }); + + it('reuses the retrieved schema reference when content is unchanged', () => { + const su = makeSchemaUtils(SIMPLE_SCHEMA); + const existingRetrievedSchema = su.retrieveSchema(SIMPLE_SCHEMA, undefined); + const currentState: Partial = { schemaUtils: su, retrievedSchema: existingRetrievedSchema }; + const state = getStateFromProps(makeProps(), currentState, undefined, existingRetrievedSchema); + expect(state.retrievedSchema).toBe(existingRetrievedSchema); + }); +}); + +// ─── propsAreEqual ──────────────────────────────────────────────────────────── + +describe('propsAreEqual', () => { + const base = makeProps({ formData: 'hello' }); + + it("returns false for 'always' strategy regardless of changes", () => { + const same = makeProps({ formData: 'hello', experimental_componentUpdateStrategy: 'always' }); + expect(propsAreEqual(same, same)).toBe(false); + }); + + it("returns true for 'customDeep' when props are deeply equal", () => { + const a = makeProps({ formData: 'hello' }); + const b = makeProps({ formData: 'hello' }); + expect(propsAreEqual(a, b)).toBe(true); + }); + + it("returns false for 'customDeep' when props differ", () => { + const a = makeProps({ formData: 'hello' }); + const b = makeProps({ formData: 'world' }); + expect(propsAreEqual(a, b)).toBe(false); + }); + + it("treats different function references as equal under 'customDeep'", () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const a = makeProps({ onChange: () => {} }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const b = makeProps({ onChange: () => {} }); + expect(propsAreEqual(a, b)).toBe(true); + }); + + it("returns true for 'shallow' when all values are reference-equal", () => { + const onChange = jest.fn(); + const a = makeProps({ onChange }); + const b = makeProps({ onChange }); + expect( + propsAreEqual( + { ...a, experimental_componentUpdateStrategy: 'shallow' }, + { ...b, experimental_componentUpdateStrategy: 'shallow' }, + ), + ).toBe(true); + }); + + it("returns false for 'shallow' when a function reference differs", () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const a = makeProps({ onChange: () => {}, experimental_componentUpdateStrategy: 'shallow' }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const b = makeProps({ onChange: () => {}, experimental_componentUpdateStrategy: 'shallow' }); + expect(propsAreEqual(a, b)).toBe(false); + }); + + it("returns false for 'shallow' when a primitive value differs", () => { + const a = makeProps({ formData: 'a', experimental_componentUpdateStrategy: 'shallow' }); + const b = makeProps({ formData: 'b', experimental_componentUpdateStrategy: 'shallow' }); + expect(propsAreEqual(a, b)).toBe(false); + }); + + it("defaults to 'customDeep' when no strategy is set", () => { + expect(propsAreEqual(base, { ...base })).toBe(true); + }); +}); + +// ─── formReducer ───────────────────────────────────────────────────────────── + +describe('formReducer', () => { + const schemaUtils = makeSchemaUtils(); + const baseState: FormState = { + schema: SIMPLE_SCHEMA, + uiSchema: {}, + fieldPathId: { [ID_KEY]: 'root', path: ['root'] }, + schemaUtils, + formData: undefined, + edit: false, + errors: [], + errorSchema: {}, + schemaValidationErrors: [], + schemaValidationErrorSchema: {}, + retrievedSchema: SIMPLE_SCHEMA, + initialDefaultsGenerated: false, + registry: { ...getDefaultRegistry(), schemaUtils }, + }; + + it('SET_SCHEMA merges schema-related payload', () => { + const newSchema = { type: 'number' } as RJSFSchema; + const newSchemaUtils = makeSchemaUtils(newSchema); + const next = formReducer(baseState, { + type: 'SET_SCHEMA', + payload: { schema: newSchema, schemaUtils: newSchemaUtils }, + }); + expect(next.schema).toBe(newSchema); + expect(next.schemaUtils).toBe(newSchemaUtils); + // Other state is preserved + expect(next.formData).toBeUndefined(); + expect(next.errors).toEqual([]); + }); + + it('SET_FORM_DATA merges form-data payload', () => { + const next = formReducer(baseState, { + type: 'SET_FORM_DATA', + payload: { formData: 'new value', edit: true }, + }); + expect(next.formData).toBe('new value'); + expect(next.edit).toBe(true); + // Other state is preserved + expect(next.schema).toBe(SIMPLE_SCHEMA); + }); + + it('SET_ERRORS merges error payload', () => { + const errors = [{ name: 'minLength', property: '.', message: 'too short', stack: '. too short', params: {} }]; + const errorSchema = new ErrorSchemaBuilder().addErrors(['too short']).ErrorSchema; + const next = formReducer(baseState, { + type: 'SET_ERRORS', + payload: { errors, errorSchema }, + }); + expect(next.errors).toBe(errors); + expect(next.errorSchema).toBe(errorSchema); + // Other state is preserved + expect(next.formData).toBeUndefined(); + expect(next.schema).toBe(SIMPLE_SCHEMA); + }); + + it('SET_STATE merges any partial state', () => { + const next = formReducer(baseState, { + type: 'SET_STATE', + payload: { formData: 'test', edit: true, initialDefaultsGenerated: true }, + }); + expect(next.formData).toBe('test'); + expect(next.edit).toBe(true); + expect(next.initialDefaultsGenerated).toBe(true); + expect(next.schema).toBe(SIMPLE_SCHEMA); + }); + + it('does not mutate the original state', () => { + const frozen = Object.freeze({ ...baseState }); + expect(() => formReducer(frozen, { type: 'SET_FORM_DATA', payload: { formData: 'x' } })).not.toThrow(); + expect(frozen.formData).toBeUndefined(); + }); +}); diff --git a/packages/core/test/withTheme.test.tsx b/packages/core/test/withTheme.test.tsx index e848815222..c8f6675473 100644 --- a/packages/core/test/withTheme.test.tsx +++ b/packages/core/test/withTheme.test.tsx @@ -2,7 +2,7 @@ import { Component, createRef } from 'react'; import { RJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; -import Form, { FormProps, ThemeProps, withTheme } from '../src'; +import { Form, FormProps, ThemeProps, withTheme } from '../src'; import { createComponent } from './testUtils'; function WrapperClassComponent(props: ThemeProps) {