Browse Source

fix(@vben-core/form-ui): 修复嵌套部分更新与重复初始化绑定残留 (#8315)

* fix(@vben-core/form-ui): fix partial updates and bindings

* fix(@vben-core/form-ui): guard cyclic patches and restore api docs table
pull/8317/head
dream-weave 1 month ago
committed by GitHub
parent
commit
62cf6f4665
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      docs/src/components/common-ui/vben-form.md
  2. 4
      docs/src/en/components/common-ui/vben-form.md
  3. 62
      packages/@core/ui-kit/form-ui/__tests__/config.test.ts
  4. 149
      packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
  5. 39
      packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts
  6. 9
      packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
  7. 36
      packages/@core/ui-kit/form-ui/src/config.ts
  8. 63
      packages/@core/ui-kit/form-ui/src/form-api.ts
  9. 9
      packages/@core/ui-kit/form-ui/src/types.ts

6
docs/src/components/common-ui/vben-form.md

@ -42,6 +42,8 @@ outline: deep
每个应用都有不同的 UI 框架,所以在应用的 `src/adapter/form` 和 `src/adapter/component` 内部,你可以根据自己的需求,进行组件适配。下面是 `Ant Design Vue` 的适配器示例代码,可根据注释查看说明:
必须先初始化组件适配器,再调用 `setupVbenForm`。每次调用都会以当前全局组件注册表重建组件及模型属性映射;重复初始化时,已从注册表移除的组件会同步清理,内置组件及其默认绑定保持不变。
::: details ant design vue 表单适配器
```ts
@ -417,7 +419,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| validateAndSubmit | 校验通过后提交表单 | `() => Promise<TSubmitValues \| undefined>` | - |
| reset | 重置表单 | `(state?: FormResetState<TFormValues>, options?: FormResetOptions) => Promise<void>` | - |
| clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | `(fieldNames?: FormFieldName<TFormValues> \| FormFieldName<TFormValues>[]) => Promise<void>` | - |
| setValues | 设置表单组件值,默认会过滤不在 schema 中定义的字段 | `(fields: Partial<TFormValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| setValues | 深层补丁更新表单值,默认会过滤不在 schema 中定义的字段 | `(fields: FormValuePatch<TFormValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| setSubmitValues | 通过 codec.decode 回填完整提交值 | `(values: TSubmitValues, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取经过 codec.encode 或旧格式化管道的提交值 | `() => Promise<TSubmitValues>` | - |
| getRawValues | 获取未格式化的独立表单值快照 | `() => Promise<TFormValues>` | - |
@ -434,6 +436,8 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| getFieldComponentRef | 获取指定字段的组件实例 | `<T=unknown>(fieldName: string)=>T` | >5.5.3 |
| getFocusedField | 获取当前已获得焦点的字段 | `()=>string\|undefined` | >5.5.3 |
`setValues` 在默认的 `filterFields=true` 模式下会将普通对象作为深层补丁合并,因此更新 `profile.email` 时会保留 `profile` 下其他已声明字段和默认值。数组、日期、Day.js、`null`、`undefined` 等叶值仍会整体覆盖。需要替换整个对象分支时,请使用 `setFieldValue('profile', nextProfile)`;需要绕过 schema 字段过滤时,可以将 `filterFields` 设为 `false`。
旧命名 `submitForm`、`validateAndSubmitForm`、`resetForm`、`resetValidate` 分别对应 `submit`、`validateAndSubmit`、`reset`、`clearValidation`。它们仍可调用,但已标记 `@deprecated`,开发环境每个旧名称只警告一次,生产环境静默。
### FormContextApi 响应式读取

4
docs/src/en/components/common-ui/vben-form.md

@ -41,6 +41,8 @@ The current adapter pattern is:
- map special `v-model:*` prop names through `modelPropNameMap`
- keep the form empty state aligned with the actual UI library behavior
Each `setupVbenForm` call rebuilds the component and model-prop mappings from the current shared component registry. Repeated setup removes components that are no longer registered while preserving built-in components and their default bindings.
### Form Adapter Example
```ts
@ -227,6 +229,8 @@ Create the form through `useVbenForm`:
Use `useVbenForm<TFormValues, TSubmitValues>` to declare component-facing form values and submission values separately. Schema, slots, selectors, and `setValues` use `TFormValues`; `getValues()` and `submit()` return `Promise<TSubmitValues>`, while `submit()` only accepts an optional native `Event`; the first `handleSubmit` argument is `TSubmitValues`. Pass one generic when both shapes are identical.
`setValues` accepts a deep `FormValuePatch<TFormValues>`. With the default `filterFields=true`, plain objects are merged as patches before fields outside the schema are removed, so updating `profile.email` preserves declared sibling fields and defaults under `profile`. Arrays, dates, Day.js values, `null`, and `undefined` replace the corresponding value atomically. Use `setFieldValue('profile', nextProfile)` to replace an entire object branch, or set `filterFields` to `false` to bypass schema filtering.
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';

62
packages/@core/ui-kit/form-ui/__tests__/config.test.ts

@ -0,0 +1,62 @@
import { defineComponent } from 'vue';
import { globalShareState } from '@vben-core/shared/global-state';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
COMPONENT_BIND_EVENT_MAP,
COMPONENT_MAP,
setupVbenForm,
} from '../src/config';
const builtInCheckbox = COMPONENT_MAP.VbenCheckbox;
const componentMapReference = COMPONENT_MAP;
const bindEventMapReference = COMPONENT_BIND_EVENT_MAP;
function resetFormConfig() {
globalShareState.setComponents({});
setupVbenForm({ config: {} });
}
beforeEach(resetFormConfig);
afterEach(resetFormConfig);
describe('setupVbenForm', () => {
it('rebuilds user mappings while preserving built-ins and record identity', () => {
const FirstInput = defineComponent({});
const SecondInput = defineComponent({});
globalShareState.setComponents({ FirstInput });
setupVbenForm({ config: { baseModelPropName: 'value' } });
expect(COMPONENT_MAP.FirstInput).toBe(FirstInput);
expect(COMPONENT_BIND_EVENT_MAP.FirstInput).toBe('value');
globalShareState.setComponents({ SecondInput });
setupVbenForm({ config: {} });
expect(COMPONENT_MAP).toBe(componentMapReference);
expect(COMPONENT_BIND_EVENT_MAP).toBe(bindEventMapReference);
expect(Reflect.has(COMPONENT_MAP, 'FirstInput')).toBe(false);
expect(Reflect.has(COMPONENT_BIND_EVENT_MAP, 'FirstInput')).toBe(false);
expect(COMPONENT_MAP.SecondInput).toBe(SecondInput);
expect(COMPONENT_BIND_EVENT_MAP.SecondInput).toBeUndefined();
expect(COMPONENT_MAP.VbenCheckbox).toBe(builtInCheckbox);
expect(COMPONENT_BIND_EVENT_MAP.VbenCheckbox).toBe('checked');
});
it('prefers component mappings over the base model prop name', () => {
const CustomInput = defineComponent({});
globalShareState.setComponents({ CustomInput });
setupVbenForm({
config: {
baseModelPropName: 'value',
modelPropNameMap: { CustomInput: 'checked' },
},
});
expect(COMPONENT_BIND_EVENT_MAP.CustomInput).toBe('checked');
});
});

149
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts

@ -374,6 +374,155 @@ describe('formApi', () => {
);
});
it('should preserve nested schema siblings in touched branches', async () => {
const setValuesMock = vi.fn();
formApi.setState({
schema: [
{ component: 'text', fieldName: 'profile.email' },
{ component: 'text', fieldName: 'profile.nickname' },
],
});
const formActions: any = {
meta: {},
setValues: setValuesMock,
values: {
profile: {
email: 'old@example.com',
ignored: true,
nickname: 'Ada',
},
untouched: 'keep',
},
};
await formApi.mount(formActions, new Map());
await formApi.setValues({
profile: { email: 'new@example.com' },
});
expect(setValuesMock).toHaveBeenCalledWith(
{
profile: {
email: 'new@example.com',
nickname: 'Ada',
},
},
false,
);
});
it('should patch object fields while replacing atomic values', async () => {
class AtomicValue {
constructor(readonly value: string) {}
}
const setValuesMock = vi.fn();
const atomicValue = new AtomicValue('new');
const updatedAt = new Date('2026-08-27T00:00:00.000Z');
const fields = {
atomicValue,
cleared: undefined,
contacts: [{ name: 'Grace' }],
nullable: null,
profile: { email: 'new@example.com' },
updatedAt,
};
formApi.setState({
schema: [
{ component: 'text', fieldName: 'atomicValue' },
{ component: 'text', fieldName: 'cleared' },
{ component: 'text', fieldName: 'contacts' },
{ component: 'text', fieldName: 'nullable' },
{ component: 'text', fieldName: 'profile' },
{ component: 'text', fieldName: 'updatedAt' },
],
});
const formActions: any = {
meta: {},
setValues: setValuesMock,
values: {
atomicValue: new AtomicValue('old'),
cleared: 'remove',
contacts: [{ name: 'Ada' }],
nullable: 'remove',
profile: { bio: 'Mathematician', email: 'old@example.com' },
updatedAt: new Date('2026-08-26T00:00:00.000Z'),
},
};
await formApi.mount(formActions, new Map());
await formApi.setValues(fields, true, true);
expect(fields).toEqual({
atomicValue,
cleared: undefined,
contacts: [{ name: 'Grace' }],
nullable: null,
profile: { email: 'new@example.com' },
updatedAt,
});
expect(setValuesMock).toHaveBeenCalledWith(
{
atomicValue,
cleared: undefined,
contacts: [{ name: 'Grace' }],
nullable: null,
profile: {
bio: 'Mathematician',
email: 'new@example.com',
},
updatedAt,
},
true,
);
expect(setValuesMock.mock.calls[0]?.[0]?.atomicValue).toBeInstanceOf(
AtomicValue,
);
});
it('should handle cyclic plain-object patches without recursion overflow', async () => {
const setValuesMock = vi.fn();
const profile: Record<string, unknown> = { email: 'new@example.com' };
profile.self = profile;
formApi.setState({
schema: [{ component: 'text', fieldName: 'profile.email' }],
});
const formActions: any = {
meta: {},
setValues: setValuesMock,
values: { profile: { bio: 'Mathematician' } },
};
await formApi.mount(formActions, new Map());
await formApi.setValues({ profile });
expect(setValuesMock).toHaveBeenCalledTimes(1);
expect(setValuesMock).toHaveBeenCalledWith(
{ profile: { email: 'new@example.com' } },
false,
);
});
it('should preserve raw-key fields during filtered updates', async () => {
const setValuesMock = vi.fn();
formApi.setState({
schema: [{ component: 'text', fieldName: '[profile.email]' }],
});
const formActions: any = {
meta: {},
setValues: setValuesMock,
values: { 'profile.email': 'old@example.com' },
};
await formApi.mount(formActions, new Map());
await formApi.setValues({ 'profile.email': 'new@example.com' });
expect(setValuesMock).toHaveBeenCalledWith(
{ 'profile.email': 'new@example.com' },
false,
);
});
it('should reset form', async () => {
const resetMock = vi.fn();
const formActions: any = {

39
packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts

@ -1081,6 +1081,45 @@ describe('useVbenForm integration', () => {
});
});
it('preserves nested defaults during partial setValues updates', async () => {
interface NestedFormValues {
profile: {
email: string;
nickname: string;
};
}
const [Form, formApi] = useVbenForm<NestedFormValues>({
schema: [
{
component: TestInput,
defaultValue: 'old@example.com',
fieldName: 'profile.email',
},
{
component: TestInput,
defaultValue: 'Ada',
fieldName: 'profile.nickname',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setValues({
profile: { email: 'new@example.com' },
});
await flushPromises();
expect(await formApi.getRawValues()).toEqual({
profile: {
email: 'new@example.com',
nickname: 'Ada',
},
});
});
it('retains initial values for unspecified fields on partial reset', async () => {
const [Form, formApi] = useVbenForm({
schema: [

9
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts

@ -6,6 +6,7 @@ import type {
FormFieldOptions,
FormItemDependencies,
FormValidationResult,
FormValuePatch,
FormValueSnapshot,
VbenFormAdapterOptions,
VbenFormProps,
@ -121,7 +122,13 @@ describe('form public types', () => {
}>();
expectTypeOf(typedFormApi.setValues)
.parameter(0)
.toEqualTypeOf<Partial<AccountFormValues>>();
.toEqualTypeOf<FormValuePatch<AccountFormValues>>();
expectTypeOf<
FormValuePatch<AccountFormValues>['profile']
>().toEqualTypeOf<undefined | { nickname?: string }>();
expectTypeOf<FormValuePatch<AccountFormValues>['roles']>().toEqualTypeOf<
string[] | undefined
>();
expectTypeOf(typedFormApi.form.values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(contextApi.getFieldValue('email')).toEqualTypeOf<string>();
expectTypeOf(

36
packages/@core/ui-kit/form-ui/src/config.ts

@ -26,7 +26,7 @@ const DEFAULT_MODEL_PROP_NAME = 'modelValue';
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
const BUILT_IN_COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
VbenCheckbox,
@ -37,12 +37,29 @@ export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
VbenSelect,
};
export const COMPONENT_BIND_EVENT_MAP: Partial<
const BUILT_IN_COMPONENT_BIND_EVENT_MAP: Partial<
Record<BaseFormComponentType, string>
> = {
VbenCheckbox: 'checked',
};
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
...BUILT_IN_COMPONENT_MAP,
};
export const COMPONENT_BIND_EVENT_MAP: Partial<
Record<BaseFormComponentType, string>
> = {
...BUILT_IN_COMPONENT_BIND_EVENT_MAP,
};
function replaceRecord<T extends object>(target: T, source: T) {
for (const key of Object.keys(target)) {
Reflect.deleteProperty(target, key);
}
Object.assign(target, source);
}
export function setupVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormAdapterOptions<T>) {
@ -74,18 +91,27 @@ export function setupVbenForm<
| undefined;
const components = globalShareState.getComponents();
const nextComponentMap = {
...BUILT_IN_COMPONENT_MAP,
...components,
} as Record<BaseFormComponentType, Component>;
const nextBindEventMap = {
...BUILT_IN_COMPONENT_BIND_EVENT_MAP,
} as Partial<Record<BaseFormComponentType, string>>;
for (const component of Object.keys(components)) {
const key = component as BaseFormComponentType;
COMPONENT_MAP[key] = components[component as never];
if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) {
COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName;
nextBindEventMap[key] = baseModelPropName;
}
// 覆盖特殊组件的modelPropName
if (modelPropNameMap && modelPropNameMap[key]) {
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
nextBindEventMap[key] = modelPropNameMap[key];
}
}
replaceRecord(COMPONENT_MAP, nextComponentMap);
replaceRecord(COMPONENT_BIND_EVENT_MAP, nextBindEventMap);
}

63
packages/@core/ui-kit/form-ui/src/form-api.ts

@ -8,6 +8,7 @@ import type {
FormResetOptions,
FormResetState,
FormSchema,
FormValuePatch,
FormValues,
FormValueSnapshot,
VbenFormProps,
@ -19,10 +20,7 @@ import { Store } from '@vben-core/shared/store';
import {
bindMethods,
cloneDeep,
isDate,
isDayjsObject,
isFunction,
isObject,
mergeWithArrayOverride,
StateHandler,
} from '@vben-core/shared/utils';
@ -46,6 +44,36 @@ type FormApiSchema<
P extends Record<string, any>,
> = FormSchema<T, P, TValues>;
function isPlainFormObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
function mergeFormValuePatch(
currentValue: unknown,
nextValue: unknown,
visited = new WeakMap<object, Record<string, unknown>>(),
): unknown {
if (!isPlainFormObject(nextValue)) {
return cloneDeep(nextValue);
}
const cached = visited.get(nextValue);
if (cached) {
return cached;
}
const result = isPlainFormObject(currentValue) ? cloneDeep(currentValue) : {};
visited.set(nextValue, result);
for (const [key, value] of Object.entries(nextValue)) {
result[key] = mergeFormValuePatch(result[key], value, visited);
}
return result;
}
function getDefaultState<
TFormValues extends FormValues,
T extends BaseFormComponentType,
@ -452,7 +480,11 @@ export class FormApi<
);
}
const formValues = decodeFormValues(codec, values);
await this.setValues(formValues, filterFields, shouldValidate);
await this.setValues(
formValues as FormValuePatch<TFormValues>,
filterFields,
shouldValidate,
);
}
/**
@ -462,16 +494,24 @@ export class FormApi<
* @param shouldValidate
*/
async setValues(
fields: Partial<TFormValues>,
fields: FormValuePatch<TFormValues>,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
const form = await this.getForm();
if (!filterFields) {
form.setValues(fields, shouldValidate);
form.setValues(fields as Partial<TFormValues>, shouldValidate);
return;
}
const currentValues = toRaw(form.values ?? {}) as Record<string, unknown>;
const mergedFields = Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
mergeFormValuePatch(currentValues[key], value),
]),
);
const schemaFieldPaths = (this.state?.schema ?? []).map(
(schema) => resolveFieldNamePath(schema.fieldName).pathSegments,
);
@ -479,12 +519,7 @@ export class FormApi<
value: unknown,
parentPath: string[] = [],
): unknown => {
if (
!isObject(value) ||
Array.isArray(value) ||
isDate(value) ||
isDayjsObject(value)
) {
if (!isPlainFormObject(value)) {
return value;
}
@ -510,8 +545,8 @@ export class FormApi<
}
return result;
};
const filteredFields = filterValue(fields) as Partial<TFormValues>;
form.setValues(filteredFields as Partial<TFormValues>, shouldValidate);
const filteredFields = filterValue(mergedFields) as Partial<TFormValues>;
form.setValues(filteredFields, shouldValidate);
}
async submit(e?: Event) {

9
packages/@core/ui-kit/form-ui/src/types.ts

@ -14,6 +14,15 @@ export type FormLabelWidthContext = UnwrapNestedRefs<
export type FormValues = Record<string, any>;
export type FormValuePatch<T> = T extends
| ((...args: any[]) => unknown)
| Date
| readonly unknown[]
? T
: T extends object
? { [K in keyof T]?: FormValuePatch<T[K]> }
: T;
export interface FormCodec<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,

Loading…
Cancel
Save