From 7061928bbf3efba3ab69bfd641ccb602948d66dd Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Tue, 23 Jun 2026 12:32:23 +0200 Subject: [PATCH] feat: upgrade final-form ecosystem to latest major versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump final-form (v4→v5), final-form-arrays (v3→v4), react-final-form (v6→v7), and react-final-form-arrays (v3→v5). All packages migrated from Flow to TypeScript with stricter type signatures. Key type adaptations: - FormProps now takes 0-1 generic args (was 2) - UseFieldConfig is no longer generic - FormState/FormApi require InitialFormValues extends Partial - FieldMetaState fields (valid, validating, active) are now optional Replace final-form-focus v2 with an inline focus decorator that preserves the v1 submit-only focus behavior. The v2 package introduced an unintended regression: it subscribes to errors continuously and steals focus during typing, breaking form interactions. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 120 +++++++----------- packages/common/src/wizard/enter-handler.ts | 2 +- .../src/validation-error/validation-error.ts | 11 +- .../src/wizard/step-buttons.tsx | 6 +- packages/react-form-renderer/package.json | 9 +- .../src/form-renderer/focus-decorator.ts | 62 +++++++++ .../src/form-renderer/form-renderer.tsx | 16 +-- .../src/renderer-context/renderer-context.ts | 2 +- .../src/use-field-api/use-field-api.ts | 2 +- 9 files changed, 135 insertions(+), 95 deletions(-) create mode 100644 packages/react-form-renderer/src/form-renderer/focus-decorator.ts diff --git a/package-lock.json b/package-lock.json index 116c03ed1..1e2b3ea38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8380,9 +8380,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8397,9 +8394,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8414,9 +8408,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8431,9 +8422,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -16126,39 +16114,21 @@ } }, "node_modules/final-form": { - "version": "4.20.10", - "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", - "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-5.0.1.tgz", + "integrity": "sha512-Ohygw2lDgc2HNynfUu82Jp5U45+OLLeBQcwWbrex1IAbQw0uNaFMUbm5dhYCF6y7jcORgl5tg19/1zYxlJtLmg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/final-form" } }, - "node_modules/final-form-arrays": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", - "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", - "license": "MIT", - "peerDependencies": { - "final-form": "^4.20.8" - } - }, - "node_modules/final-form-focus": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/final-form-focus/-/final-form-focus-1.1.2.tgz", - "integrity": "sha512-Gd+Bd2Ll7ijo3/sd6kJ/bwLkhc2bUJPxTON6fIqee/008EJpACWhT+zoWCm9q6NcfMcWRS+Sp5ikRX8iqdXeGQ==", - "license": "MIT", - "peerDependencies": { - "final-form": ">=1.3.0" - } - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -26667,38 +26637,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-final-form": { - "version": "6.5.9", - "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", - "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.15.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/final-form" - }, - "peerDependencies": { - "final-form": "^4.20.4", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-final-form-arrays": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", - "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.19.4" - }, - "peerDependencies": { - "final-form": "^4.15.0", - "final-form-arrays": ">=1.0.4", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-final-form": "^6.2.1" - } - }, "node_modules/react-github-btn": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/react-github-btn/-/react-github-btn-1.4.0.tgz", @@ -30999,12 +30937,11 @@ "version": "4.1.14", "license": "Apache-2.0", "dependencies": { - "final-form": "^4.20.10", - "final-form-arrays": "^3.0.2", - "final-form-focus": "^1.1.2", + "final-form": "^5.0.1", + "final-form-arrays": "^4.0.1", "lodash": "^4.18.1", - "react-final-form": "^6.5.0", - "react-final-form-arrays": "^3.1.1" + "react-final-form": "^7.0.1", + "react-final-form-arrays": "^5.0.0" }, "devDependencies": { "process": "^0.11.10" @@ -31014,6 +30951,47 @@ "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, + "packages/react-form-renderer/node_modules/final-form-arrays": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-4.0.1.tgz", + "integrity": "sha512-aIGGN1ibtxxmRR4LsIEhSbWbUKyHAy9BCAUEcG19U1OpYqD1M9dU9MxgXHmGoM6JLegD4DEVKFLxJjI0q1VjhQ==", + "license": "MIT", + "peerDependencies": { + "final-form": "^5.0.0" + } + }, + "packages/react-form-renderer/node_modules/react-final-form": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-7.0.1.tgz", + "integrity": "sha512-8K1ITLecBI7F+jCxiMA/JI1amHT/+klQB+nhl5YFZu3R1sQftbiQk/UfwRDvE7ZxIqIvCf8nYLDQRrfwaDT3kQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.15.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/final-form" + }, + "peerDependencies": { + "final-form": "^5.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "packages/react-form-renderer/node_modules/react-final-form-arrays": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-5.0.0.tgz", + "integrity": "sha512-M7J73727AaK0YPJbvf/NiBKZrxnCiYZWSuRSHjg6iiA9+KrSuuRm5Y+8gLsah1ziVk4jH4eLK/Atl26LPkB1Dg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.19.4" + }, + "peerDependencies": { + "final-form": "^5.0.1", + "final-form-arrays": "^4.0.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-final-form": "^7.0.1" + } + }, "packages/react-renderer-demo": { "name": "@data-driven-forms/react-renderer-demo", "version": "4.1.2", diff --git a/packages/common/src/wizard/enter-handler.ts b/packages/common/src/wizard/enter-handler.ts index c6e605392..6742fa7d1 100644 --- a/packages/common/src/wizard/enter-handler.ts +++ b/packages/common/src/wizard/enter-handler.ts @@ -4,7 +4,7 @@ import { AnyObject } from '@data-driven-forms/react-form-renderer'; interface FormOptions { valid: boolean; - getState: () => AnyObject & { validating: boolean; values: AnyObject }; + getState: () => AnyObject & { validating?: boolean; values: AnyObject }; getRegisteredFields: () => AnyObject; } diff --git a/packages/mui-component-mapper/src/validation-error/validation-error.ts b/packages/mui-component-mapper/src/validation-error/validation-error.ts index e30a6931b..f315d38ef 100644 --- a/packages/mui-component-mapper/src/validation-error/validation-error.ts +++ b/packages/mui-component-mapper/src/validation-error/validation-error.ts @@ -1,15 +1,16 @@ -import type { FieldMetaState } from 'react-final-form'; - -export interface ExtendedFieldMeta extends FieldMetaState, Record { +export interface ExtendedFieldMeta extends Record { + error?: string; + submitError?: string; + touched?: boolean; warning?: any; } -export const validationError = (meta: ExtendedFieldMeta, validateOnMount?: boolean): string | undefined => { +export const validationError = (meta: ExtendedFieldMeta, validateOnMount?: boolean): string | false | undefined => { if (validateOnMount) { return meta.error || meta.submitError; } - return meta.touched && (meta.error || meta.submitError); + return meta.touched ? meta.error || meta.submitError : false; }; export default validationError; diff --git a/packages/mui-component-mapper/src/wizard/step-buttons.tsx b/packages/mui-component-mapper/src/wizard/step-buttons.tsx index c69984998..3c98c434e 100644 --- a/packages/mui-component-mapper/src/wizard/step-buttons.tsx +++ b/packages/mui-component-mapper/src/wizard/step-buttons.tsx @@ -39,9 +39,9 @@ const StyledGrid = styled(Grid)(() => ({ interface FormState { values: AnyObject; - valid: boolean; - validating: boolean; - submitting: boolean; + valid?: boolean; + validating?: boolean; + submitting?: boolean; } interface NextButtonProps { diff --git a/packages/react-form-renderer/package.json b/packages/react-form-renderer/package.json index 6972993d2..cb4c180a5 100644 --- a/packages/react-form-renderer/package.json +++ b/packages/react-form-renderer/package.json @@ -28,12 +28,11 @@ "process": "^0.11.10" }, "dependencies": { - "final-form": "^4.20.10", - "final-form-arrays": "^3.0.2", - "final-form-focus": "^1.1.2", + "final-form": "^5.0.1", + "final-form-arrays": "^4.0.1", "lodash": "^4.18.1", - "react-final-form": "^6.5.0", - "react-final-form-arrays": "^3.1.1" + "react-final-form": "^7.0.1", + "react-final-form-arrays": "^5.0.0" }, "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0.0", diff --git a/packages/react-form-renderer/src/form-renderer/focus-decorator.ts b/packages/react-form-renderer/src/form-renderer/focus-decorator.ts new file mode 100644 index 000000000..dd6dcc03e --- /dev/null +++ b/packages/react-form-renderer/src/form-renderer/focus-decorator.ts @@ -0,0 +1,62 @@ +import { FormApi, getIn } from 'final-form'; + +type FocusableInput = { name: string; focus: () => void }; +type GetInputs = () => FocusableInput[]; +type FindInput = (inputs: FocusableInput[], errors: Record) => FocusableInput | undefined; + +const isFocusableInput = (el: any): boolean => !!(el && typeof el.focus === 'function'); + +const defaultGetInputs: GetInputs = () => { + if (typeof document === 'undefined') return []; + return Array.prototype.slice + .call(document.forms) + .reduce((acc: FocusableInput[], form: HTMLFormElement) => { + return acc.concat(Array.prototype.slice.call(form).filter(isFocusableInput)); + }, []); +}; + +const defaultFindInput: FindInput = (inputs, errors) => + inputs.find((input) => input.name && getIn(errors, input.name)); + +const createFocusDecorator = (getInputs?: GetInputs, findInputFn?: FindInput) => { + return (form: FormApi) => { + const focusOnFirstError = (errors: Record) => { + const inputs = (getInputs || defaultGetInputs)(); + const firstInput = (findInputFn || defaultFindInput)(inputs, errors); + if (firstInput) firstInput.focus(); + }; + + const originalSubmit = form.submit; + let state: { errors?: Record; submitErrors?: Record } = {}; + + const unsubscribe = form.subscribe( + (nextState) => { state = nextState; }, + { errors: true, submitErrors: true } + ); + + const afterSubmit = () => { + if (state.errors && Object.keys(state.errors).length) { + focusOnFirstError(state.errors); + } else if (state.submitErrors && Object.keys(state.submitErrors).length) { + focusOnFirstError(state.submitErrors); + } + }; + + form.submit = () => { + const result = originalSubmit.call(form); + if (result && typeof result.then === 'function') { + result.then(afterSubmit, () => {}); + } else { + afterSubmit(); + } + return result; + }; + + return () => { + unsubscribe(); + form.submit = originalSubmit; + }; + }; +}; + +export default createFocusDecorator; diff --git a/packages/react-form-renderer/src/form-renderer/form-renderer.tsx b/packages/react-form-renderer/src/form-renderer/form-renderer.tsx index d457d3721..851f2ed7f 100644 --- a/packages/react-form-renderer/src/form-renderer/form-renderer.tsx +++ b/packages/react-form-renderer/src/form-renderer/form-renderer.tsx @@ -1,5 +1,5 @@ import arrayMutators from 'final-form-arrays'; -import createFocusDecorator from 'final-form-focus'; +import createFocusDecorator from './focus-decorator'; import React, { useCallback, useMemo, useRef, useState, cloneElement, ReactNode, ComponentType, FunctionComponent, ReactElement } from 'react'; import { FormProps } from 'react-final-form'; import { FormApi } from 'final-form'; @@ -7,7 +7,7 @@ import { FormApi } from 'final-form'; import defaultSchemaValidator from '../default-schema-validator'; import defaultValidatorMapper from '../validator-mapper'; import Form from '../form'; -import RendererContext from '../renderer-context'; +import RendererContext, { FormOptions } from '../renderer-context'; import renderForm from './render-form'; import SchemaErrorComponent from './schema-error-component'; import Schema from '../common-types/schema'; @@ -21,14 +21,14 @@ import { ConditionMapper } from './condition-mapper'; export interface FormRendererProps< FormValues = Record, - InitialFormValues = Partial, + InitialFormValues extends Partial = Partial, FormTemplateProps extends FormTemplateRenderProps = FormTemplateRenderProps -> extends Omit>, 'onSubmit' | 'children'> { +> extends Omit>, 'onSubmit' | 'children'> { initialValues?: InitialFormValues; onCancel?: (values: FormValues, ...args: any[]) => void; onReset?: () => void; onError?: (...args: any[]) => void; - onSubmit?: FormProps['onSubmit']; + onSubmit?: FormProps['onSubmit']; schema: Schema | (Record & { fields: Array> }); clearOnUnmount?: boolean; clearedValue?: any; @@ -82,7 +82,7 @@ const renderChildren = (children: ReactNode | ((props: Record) => R function FormRenderer< FormValues = Record, - InitialFormValues = Partial, + InitialFormValues extends Partial = Partial, FTP extends FormTemplateRenderProps = FormTemplateRenderProps >({ actionMapper, @@ -214,7 +214,7 @@ function FormRenderer< onCancel: isFunc(onCancel) ? handleCancelCallback(getState) : undefined, onReset: handleResetCallback(reset), onError: handleErrorCallback, - getState, + getState: getState as FormOptions['getState'], valid, clearedValue, submit, @@ -240,7 +240,7 @@ function FormRenderer< )} {...props} - initialValues={initialValues} + initialValues={initialValues as Partial} /> ); } diff --git a/packages/react-form-renderer/src/renderer-context/renderer-context.ts b/packages/react-form-renderer/src/renderer-context/renderer-context.ts index a6843cf6c..8e39b1c8a 100644 --- a/packages/react-form-renderer/src/renderer-context/renderer-context.ts +++ b/packages/react-form-renderer/src/renderer-context/renderer-context.ts @@ -7,7 +7,7 @@ import Field from '../common-types/field'; import Schema from '../common-types/schema'; import { ConditionMapper } from '../form-renderer/condition-mapper'; -export interface FormOptions, InitialFormValues = Partial> +export interface FormOptions, InitialFormValues extends Partial = Partial> extends FormApi { registerInputFile?: (name: keyof FormValues) => void; unRegisterInputFile?: (name: keyof FormValues) => void; diff --git a/packages/react-form-renderer/src/use-field-api/use-field-api.ts b/packages/react-form-renderer/src/use-field-api/use-field-api.ts index 5fcb31390..b6dd82f7f 100644 --- a/packages/react-form-renderer/src/use-field-api/use-field-api.ts +++ b/packages/react-form-renderer/src/use-field-api/use-field-api.ts @@ -36,7 +36,7 @@ export interface UseFieldApiConfig extends AnyObject { type?: string; } -export interface UseFieldApiComponentConfig extends UseFieldConfig { +export interface UseFieldApiComponentConfig extends UseFieldConfig { name: string; }