refactor(@vben-core/form-ui): migrate to Zod 4 and TanStack Form (#8176)
* build(@vben-core/form-ui): update form validation dependencies * refactor(@vben-core/form-ui): replace vee-validate with TanStack Form * test(@vben-core/form-ui): cover TanStack Form migration * docs(@vben/docs): document Zod 4 form migration * refactor(project): update form adapters * refactor(project): migrate application form consumers * refactor(@vben/playground): migrate form examples * docs(@vben/docs): fix validate return type and required rule empty check in form docs * fix(@vben-core/form-ui): resolve oxlint and eslint errors - rewrite nested ternary expressions as if/else in form-field-array.vue and form-runtime.ts - sort @tanstack/vue-form before @vben-core/composables in package.json * chore: fix merge artifacts and formatting - move dependencies before devDependencies in root package.json - update preferences snapshot for widget positioning fields - format form-array demo README * refactor(@vben-core/form-ui): optimize form runtime and value flow - add fine-grained field selectors and atomic dependency resolution - expose raw and formatted value snapshots with focused regression coverage * fix(@vben/layouts): dispose sortable instance on unmount * docs(@vben/docs): document form runtime API changes - list added, changed, removed, and deprecated form APIs - document atomic dependencies and raw versus formatted values
This commit is contained in:
@@ -6,6 +6,10 @@ outline: deep
|
||||
|
||||
`Vben Form` is the shared form abstraction used across different UI-library variants such as `Ant Design Vue`, `Element Plus`, `Naive UI`, and other adapters added inside this repository.
|
||||
|
||||
It uses [TanStack Form](https://tanstack.com/form/latest/docs/framework/vue/overview) internally for state and validation lifecycles, with [Zod 4](https://zod.dev/v4) schemas. Application code should continue using `useVbenForm`, `FormApi`, and the adapter layer instead of depending on the raw TanStack instance.
|
||||
|
||||
Read the [Zod 4 and TanStack Form migration guide](/en/guide/in-depth/zod-v4-form-migration) before upgrading an existing project.
|
||||
|
||||
> If some details are not obvious from the docs, check the live demos as well.
|
||||
|
||||
## Adapter Setup
|
||||
@@ -23,8 +27,9 @@ The current adapter pattern is:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FormValues,
|
||||
VbenFormProps as FormProps,
|
||||
VbenFormSchema as FormSchema,
|
||||
VbenFormProps,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import type { ComponentType } from './component';
|
||||
@@ -46,7 +51,7 @@ setupVbenForm<ComponentType>({
|
||||
Upload: 'fileList',
|
||||
},
|
||||
},
|
||||
defineRules: {
|
||||
rules: {
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
@@ -62,11 +67,20 @@ setupVbenForm<ComponentType>({
|
||||
},
|
||||
});
|
||||
|
||||
const useVbenForm = useForm<ComponentType>;
|
||||
function useVbenForm<TValues extends FormValues = FormValues>(
|
||||
options: FormProps<ComponentType, Record<never, never>, TValues>,
|
||||
) {
|
||||
return useForm<TValues, ComponentType, Record<never, never>>(options);
|
||||
}
|
||||
|
||||
export { useVbenForm, z };
|
||||
export type VbenFormSchema = FormSchema<ComponentType>;
|
||||
export type { VbenFormProps };
|
||||
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||
FormSchema<ComponentType, Record<never, never>, TValues>;
|
||||
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
|
||||
ComponentType,
|
||||
Record<never, never>,
|
||||
TValues
|
||||
>;
|
||||
```
|
||||
|
||||
### Component Adapter Example
|
||||
@@ -191,6 +205,55 @@ Create the form through `useVbenForm`:
|
||||
|
||||
<DemoPreview dir="demos/vben-form/basic" />
|
||||
|
||||
## Typed Values and Slots
|
||||
|
||||
Declare the value shape once with `useVbenForm<TValues>`. The same type flows through value APIs, callbacks, selectors, and field/default/action slots:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
interface AccountFormValues {
|
||||
email: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm<AccountFormValues>({
|
||||
handleSubmit(values) {
|
||||
return addAccount(values); // AccountFormValues
|
||||
},
|
||||
schema: [
|
||||
{ component: 'Input', fieldName: 'email', label: 'Email' },
|
||||
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
|
||||
],
|
||||
});
|
||||
|
||||
async function fillForm() {
|
||||
await formApi.setValues({ email: 'user@example.com' });
|
||||
return formApi.getValues(); // Promise<AccountFormValues>
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form>
|
||||
<template #email="{ componentField, field, formApi, values }">
|
||||
<!-- field.state.value and componentField.modelValue are strings -->
|
||||
<input v-bind="componentField" :data-email="values.email" />
|
||||
<button type="button" @click="formApi.clearValidation('email')">
|
||||
Clear
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ formApi, shapes, values }">
|
||||
<button type="button" @click="formApi.submit()">
|
||||
Submit {{ shapes.length }} fields for {{ values.email }}
|
||||
</button>
|
||||
</template>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
Named field slots expose `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`. Forms without an explicit `TValues` remain compatible with arbitrary slot names and broad props.
|
||||
|
||||
## Value Formatting
|
||||
|
||||
Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape.
|
||||
@@ -204,10 +267,25 @@ Use `schema.valueFormat` when the component value is convenient for the UI but t
|
||||
## Key API Notes
|
||||
|
||||
- `useVbenForm` returns `[Form, formApi]`
|
||||
- `useVbenForm<TValues>` propagates values through APIs, callbacks, schema callbacks, and slots
|
||||
- prefer `reset`, `submit`, `validateAndSubmit`, and `clearValidation`
|
||||
- `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development
|
||||
- `clearValidation` invalidates in-flight async results before clearing errors
|
||||
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions
|
||||
- `handleValuesChange(values, fieldsChanged)` includes the second parameter in newer versions
|
||||
- `handleValuesChange(values, fieldsChanged)` receives readonly raw form state before `valueFormat`, `fieldMappingTime`, or array-to-string conversion
|
||||
- its third `getFormattedValues` argument formats lazily, so raw-only change handlers avoid clone and transform work
|
||||
- `getRawValues()` returns only an independent raw snapshot, `getValues()` returns only the formatted payload, and `getValueSnapshot()` returns both
|
||||
- `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot
|
||||
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props
|
||||
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads
|
||||
- `formApi.form` is the stable `FormContextApi`; raw TanStack generics are intentionally not exposed
|
||||
- prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required
|
||||
- `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state
|
||||
- legacy `setupVbenForm({ defineRules })` still works, warns once in development, and is silent in production; use `rules` for new code
|
||||
- prefer `dependencies: { triggerFields, resolve(context) }` for one atomic dynamic-state patch; legacy dependency callbacks remain supported but are deprecated and warn once in development
|
||||
- top-level `componentProps`, `help`, and `renderComponentContent` functions receive `FormSchemaContext`; value-dependent rendering belongs in `dependencies.resolve`
|
||||
- use `formFieldProps.validateOn` with `blur` and/or `change`; submit always validates, and `asyncDebounceMs` debounces async validators
|
||||
- use `changeEventFallback: true` only for components that emit `change` without an `update:*` event
|
||||
|
||||
## Reference
|
||||
|
||||
|
||||
242
docs/src/en/guide/in-depth/zod-v4-form-migration.md
Normal file
242
docs/src/en/guide/in-depth/zod-v4-form-migration.md
Normal file
@@ -0,0 +1,242 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Zod 4 and TanStack Form Migration
|
||||
|
||||
This migration upgrades form schemas from Zod 3 to Zod 4 and replaces vee-validate with TanStack Form internally. The Vben business API remains stable while implementation-specific form APIs are removed from the public boundary.
|
||||
|
||||
## Dependency Changes
|
||||
|
||||
| Area | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Schema | `zod@^3.25.76` | `zod@^4.4.3` |
|
||||
| Defaults | `zod-defaults@0.1.3` | `zod-defaults@^0.2.3` |
|
||||
| Form engine | `vee-validate@^4.15.1` | `@tanstack/vue-form@^1.33.2` |
|
||||
| Zod adapter | `@vee-validate/zod@^4.15.1` | Removed; TanStack Form supports Standard Schema |
|
||||
|
||||
Source files, package manifests, and the lockfile must no longer depend on `vee-validate` or `@vee-validate/zod`.
|
||||
|
||||
## Compatibility Boundary
|
||||
|
||||
The following Vben APIs remain supported:
|
||||
|
||||
- `useVbenForm(options)` returning `[Form, formApi]`
|
||||
- existing `FormApi` methods for values, reset, validation, submission, schema updates, and component refs
|
||||
- existing `FormSchema` fields, dependencies, `valueFormat`, and array schema structure
|
||||
- application adapters and the re-exported `z` namespace
|
||||
- the existing `componentField` slot and binding shape
|
||||
|
||||
`formApi.form` is now the library-independent `FormContextApi`. It exposes values, errors, meta, set/reset/validate/submit methods, and array operations without leaking vee or raw TanStack generics.
|
||||
|
||||
New code uses `reset`, `submit`, `validateAndSubmit`, and `clearValidation`. The former `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` names remain deprecated forwarding aliases. They emit one warning per name in development and stay silent in production.
|
||||
|
||||
## Form UI API Changes in This Refactor
|
||||
|
||||
### Added APIs
|
||||
|
||||
| API | Type/Location | Description |
|
||||
| --- | --- | --- |
|
||||
| `dependencies.resolve(context)` | `FormItemDependenciesResolve` | Evaluates one complete dynamic patch from declared `triggerFields` and commits it atomically. Context contains readonly `values`, `actions`, `controller`, and row-aware `schema`. |
|
||||
| `useValues()` | `FormContextApi` | Subscribes to all form values. Use only when full-form reactivity is required. |
|
||||
| `useFieldValue(fieldName)` | `FormContextApi` | Subscribes to one field value without reacting to unrelated fields. |
|
||||
| `useFieldValues(fieldNames)` | `FormContextApi` | Subscribes to a declared group of field values. |
|
||||
| `useFieldError(fieldName)` | `FormContextApi` | Subscribes to one field error without consuming the full error object. |
|
||||
| `getRawValues()` | `FormApi` | Returns an independent raw snapshot before field mapping and `valueFormat`. |
|
||||
| `formatValues(rawValues)` | `FormApi` | Runs the unified formatting pipeline on a supplied raw snapshot. |
|
||||
| `getValueSnapshot()` | `FormApi` | Returns `{ rawValues, values }`, where `values` is the formatted payload. |
|
||||
| `asyncDebounceMs` | `FormFieldOptions` | Configures TanStack Field async validation debounce. |
|
||||
| `changeEventFallback` | `FormCommonConfig` / adapter config | Enables fallback for legacy components that emit `change` without `update:*`; defaults to `false`. |
|
||||
|
||||
`dependencies.resolve` may return `if`, `show`, `disabled`, `required`, `rules`, `componentProps`, `help`, and `renderComponentContent`. Omitting `rules` keeps the static rule; returning `rules: null` disables it.
|
||||
|
||||
### Changed APIs
|
||||
|
||||
| API | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Submit callback | `handleSubmit(values)` | `handleSubmit(values, rawValues)`; the first argument is formatted and the second is the matching readonly raw snapshot. Existing single-argument functions remain valid. |
|
||||
| Values change callback | `handleValuesChange(values, fieldsChanged)` | `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)`; formatting is lazy and incurs no clone/transform cost unless requested. |
|
||||
| Field validation triggers | Four `validateOn*` booleans | `validateOn?: readonly ('blur' \| 'change')[]`; submit always validates. |
|
||||
| Change-event compatibility | `disabledOnChangeListener: false` enabled fallback | `changeEventFallback: true` enables fallback with positive semantics. |
|
||||
| Top-level render callbacks | `componentProps(values, actions, ctx)`, `help(values, actions, ctx)`, `renderComponentContent(values, actions, ctx)` | Receive only lightweight `FormSchemaContext`; value-dependent behavior moves to `dependencies.resolve`. |
|
||||
| `validateAndSubmit()` | Repeated low-level validation/scroll handling and could validate again during submit | Delegates to canonical `validate()` and shared submission logic; invalid forms do not submit. |
|
||||
| `getValues()` | Implicitly returned transformed values | Still returns the formatted payload; use `getRawValues()` for raw state. |
|
||||
|
||||
### Removed APIs
|
||||
|
||||
| Removed API | Replacement |
|
||||
| --- | --- |
|
||||
| `FormValidationOptions` | `validate()` and `validateField(fieldName)` no longer accept options. |
|
||||
| `force` / `silent` / `validated-only` validation modes | Removed because these vee modes have no TanStack runtime semantics. |
|
||||
| `validateOnBlur` / `validateOnChange` / `validateOnInput` / `validateOnModelUpdate` | Use `formFieldProps.validateOn`; input and model updates are represented by `change`. |
|
||||
| `disabledOnChangeListener` | Use positive `changeEventFallback`. |
|
||||
| `disabledOnInputListener` | Input listeners are no longer bound automatically; provide `componentProps.onInput` explicitly when required. |
|
||||
| `values/actions` parameters from top-level schema render functions | Use `FormSchemaContext`; move value-dependent behavior to `dependencies.resolve`. |
|
||||
|
||||
### Deprecated but Supported
|
||||
|
||||
- `dependencies.if/show/disabled/required/rules/componentProps/trigger` remain compatible for this release, but every callback is marked `@deprecated` and emits one development warning. If both syntaxes bypass the type union, `resolve` wins.
|
||||
- `resetForm`, `submitForm`, `resetValidate`, and `validateAndSubmitForm` continue forwarding to canonical methods.
|
||||
- `FormActions` remains as a deprecated alias of `FormContextApi`.
|
||||
- `setupVbenForm({ defineRules })` remains supported; `rules` wins for duplicate names.
|
||||
- The re-exported `z`, `componentField` slots, and `emptyStateValue` remain unchanged.
|
||||
|
||||
### Internal Behavior Changes
|
||||
|
||||
- Field components use fine-grained value/error selectors; full error aggregation is no longer on the normal input path.
|
||||
- Async validators discard stale Promises through a Vben generation without reading private TanStack AbortController or meta fields.
|
||||
- New and legacy dependencies share one atomic executor, so stale async results cannot overwrite newer state.
|
||||
- Formatting runs in a fixed array-to-string, range mapping, schema `valueFormat` order and performs one deep clone per formatted snapshot.
|
||||
|
||||
## Typed Values and Slots
|
||||
|
||||
Application adapters keep the UI component mapping fixed and expose the business value shape as the only generic:
|
||||
|
||||
```ts
|
||||
interface AccountFormValues {
|
||||
email: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm<AccountFormValues>({
|
||||
handleSubmit(values) {
|
||||
return addAccount(values);
|
||||
},
|
||||
schema: [
|
||||
{ component: 'Input', fieldName: 'email' },
|
||||
{ component: 'Input', fieldName: 'nickname' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
`TValues` flows through `VbenFormProps`, `FormSchema`, `FormApi`, `FormContextApi`, value APIs, submit/change callbacks, selectors, and dynamic schema callbacks. The returned `Form` component also exposes typed slots: known field slots use the matching value type for `field.state.value` and `componentField.modelValue`, while all field/default/action slots receive the complete `values` and matching `formApi`. Legacy forms without `TValues` retain arbitrary slot names and broad props.
|
||||
|
||||
## New and Legacy Rule Registration
|
||||
|
||||
Use `rules` in new code:
|
||||
|
||||
```ts
|
||||
setupVbenForm({
|
||||
rules: {
|
||||
required(value, _params, context) {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
return isEmpty ? `${context.label} is required` : true;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The legacy `defineRules` option forwards to the same registry:
|
||||
|
||||
```ts
|
||||
setupVbenForm({
|
||||
defineRules: {
|
||||
required: legacyRequiredRule,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Legacy runtime usage emits one warning per deprecation key in development and no warnings in production. If both options define the same rule, `rules` wins. The `FormActions` type remains as a deprecated alias of `FormContextApi`; editors report the type deprecation because type-only usage cannot emit runtime warnings.
|
||||
|
||||
## Running the Codemod
|
||||
|
||||
Run the pinned tool against each affected tsconfig from a clean Git worktree:
|
||||
|
||||
```bash
|
||||
npx --yes zod-v3-to-v4@1.21.3 path/to/tsconfig.json
|
||||
```
|
||||
|
||||
The tool edits `.ts`, `.tsx`, and `.vue` files in place and has no dry-run mode. Always review `git diff` afterward.
|
||||
|
||||
The codemod primarily recognizes direct `zod` imports. Schemas that obtain `z` through `@vben/common-ui` or an application adapter need manual review, especially constructor errors, string formats, and dynamic refinement messages.
|
||||
|
||||
## Zod 4 Changes
|
||||
|
||||
### Unified Error Parameters
|
||||
|
||||
Replace `required_error` and `invalid_type_error` with `error`:
|
||||
|
||||
```ts
|
||||
const count = z.number({
|
||||
error: (issue) =>
|
||||
issue.input === undefined ? 'Count is required' : 'Count must be a number',
|
||||
});
|
||||
```
|
||||
|
||||
Use an `error(issue)` callback for dynamic refinement messages instead of passing a function that returns params as the second argument to `.refine()`.
|
||||
|
||||
### String Formats and Errors
|
||||
|
||||
Prefer top-level format schemas:
|
||||
|
||||
```ts
|
||||
z.email('Invalid email');
|
||||
z.url('Invalid URL');
|
||||
z.uuid('Invalid UUID');
|
||||
```
|
||||
|
||||
Read validation details from `ZodError.issues`; the old `.errors` property is removed.
|
||||
|
||||
### Defaults and Optionality
|
||||
|
||||
Zod 4 defaults may return immediately when the input is `undefined`. Review `.default().optional()` using actual parse behavior instead of internal type names.
|
||||
|
||||
Vben initial values use this precedence:
|
||||
|
||||
1. explicit schema `defaultValue`
|
||||
2. Zod `.default()`
|
||||
3. Zod 4-compatible `zod-defaults`
|
||||
4. component empty-state conventions
|
||||
|
||||
Required markers are derived from whether the schema accepts `undefined`.
|
||||
|
||||
### Wrappers, Refine, Transform, and Coerce
|
||||
|
||||
Do not read `_def`, `_zod.def`, or `typeName`. Use public `.unwrap()` APIs and public pipe inputs. Delegate intersection defaults to the Zod 4-compatible `zod-defaults` package.
|
||||
|
||||
Standard Schema validation does not write transform/coerce output back into TanStack Form state. Keep using `valueFormat` for submission payload conversion, or explicitly call `parseAsync` at the submission boundary when transformed schema output is required.
|
||||
|
||||
Also review these changes:
|
||||
|
||||
- `z.record()` should specify key and value schemas
|
||||
- `z.enum()` replaces former `nativeEnum` use cases
|
||||
- number integer, Infinity, and finite behavior
|
||||
- object strictness, merge, and unknown keys
|
||||
- intersection merge conflicts
|
||||
- coerce input types defaulting to `unknown`
|
||||
- removal of Zod 3 types such as `ZodEffects`, `ZodTypeAny`, and `AnyZodObject`
|
||||
|
||||
## Form Engine Behavior
|
||||
|
||||
`formFieldProps.validateOn` accepts `blur` and `change`, with both enabled by default; submit always validates fields. `asyncDebounceMs` configures TanStack Field async debounce. The four vee-style `validateOn*` booleans and `force/silent/validated-only` modes have been removed.
|
||||
|
||||
The shadcn form primitives now use a Vben-owned field context. Labels, controls, descriptions, and messages continue to provide ids, `aria-invalid`, `aria-describedby`, touched, dirty, valid, and error states.
|
||||
|
||||
`clearValidation(fieldNames?)` advances Vben's validator generation and clears public error state without relying on a private TanStack AbortController. A Promise that finishes later is discarded as stale. Omitting `fieldNames` covers every registered field and every field with an existing error.
|
||||
|
||||
`dependencies.resolve(context)` is the recommended API: it evaluates once and atomically commits one dynamic-state patch, while stale async results are discarded as a unit. Legacy `if/show/disabled/required/rules/componentProps/trigger` callbacks remain supported through the same normalized executor, but are marked `@deprecated` and emit one development warning. Both APIs react only to declared `triggerFields`.
|
||||
|
||||
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` receives readonly raw values and formats only when its third argument is called. `getRawValues()` and `getValues()` each create only the requested snapshot; use `getValueSnapshot()` when both are required. `handleSubmit(values, rawValues)` receives both forms at submission. The formatter performs one deep clone, then applies array-to-string, range mapping, and schema `valueFormat` in order. Array fields keep using TanStack push/remove operations and stable row identity.
|
||||
|
||||
## Test and Acceptance Matrix
|
||||
|
||||
Required coverage includes:
|
||||
|
||||
- Zod defaults, optional, nullable, intersection, pipe, transform, coerce, and errors
|
||||
- runtime values, selectors, reset, manual errors, validation, and async validation
|
||||
- field binding, blur/change triggers, error messages, ARIA, dependencies, and arrays
|
||||
- new/legacy API equivalence, warning deduplication, production silence, and type aliases
|
||||
- complete `useVbenForm` lifecycle, submission, `handleValuesChange`, submit-on-change, and async race handling
|
||||
|
||||
Acceptance requires zero TypeScript errors, zero build errors, all tests passing, no unhandled browser errors, modified-file formatting and lint passing, and no source dependency on vee or Zod private structures.
|
||||
|
||||
## References
|
||||
|
||||
- [Zod 4 release notes](https://zod.dev/v4)
|
||||
- [Zod migration guide](https://zod.dev/v4/changelog)
|
||||
- [TanStack Form Vue overview](https://tanstack.com/form/latest/docs/framework/vue/overview)
|
||||
- [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/vue/guides/validation)
|
||||
Reference in New Issue
Block a user