From 1ac5ac3e8210d6bc1e31f71c6dd3efa77776278a Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 23 Jul 2026 15:18:35 +0800 Subject: [PATCH 1/6] fix(@vben-core/form-ui): preserve array field focus --- .../__tests__/form-integration.test.ts | 31 +++++++++++++++++++ .../src/components/form-field-array.vue | 22 ++----------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts index d45e9791..63c64e21 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts @@ -485,6 +485,37 @@ describe('useVbenForm integration', () => { }); }); + it('preserves array row inputs and focus while editing', async () => { + const [Form] = useVbenForm({ + schema: [ + { + children: [ + { + component: TestInput, + fieldName: 'name', + label: 'Name', + }, + ], + defaultValue: [{ name: 'Ada' }], + fieldName: 'contacts', + type: 'array', + }, + ], + }); + const wrapper = mount(Form, { attachTo: document.body }); + wrappers.push(wrapper); + await flushPromises(); + const input = wrapper.get('input'); + const inputElement = input.element; + inputElement.focus(); + + await input.setValue('Ada Lovelace'); + await flushPromises(); + + expect(wrapper.get('input').element).toBe(inputElement); + expect(document.activeElement).toBe(inputElement); + }); + it('scopes resolve dependencies to array rows', async () => { const resolve = vi.fn(({ schema }: Record) => ({ componentProps: { diff --git a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue index 963e547a..ca9a8796 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue @@ -10,7 +10,7 @@ import { VbenIconButton, VbenRenderContent, } from '@vben-core/shadcn-ui'; -import { cn, isObject, set } from '@vben-core/shared/utils'; +import { cn, set } from '@vben-core/shared/utils'; import { injectRenderFormProps } from '../form-render/context'; import FormField from '../form-render/form-field.vue'; @@ -72,8 +72,6 @@ if (!form) { } const formActions = form; const arrayValue = formActions.useFieldValue(props.name); -const rowKeys = new WeakMap(); -let nextRowKey = 0; const fields = computed[]>(() => { return Array.isArray(arrayValue.value) ? arrayValue.value : []; @@ -124,20 +122,6 @@ function removeRow(index: number) { void formActions.removeFieldValue(arrayPath.value, index); } -function getRowKey(row: Record, index: number) { - if (!isObject(row)) { - return `${arrayPath.value}-${index}`; - } - const existingKey = rowKeys.get(row); - if (existingKey) { - return existingKey; - } - nextRowKey += 1; - const key = `${arrayPath.value}-${nextRowKey}`; - rowKeys.set(row, key); - return key; -} - function rowSchemas(index: number) { return props.schema.map((col) => createArrayChildSchema(col as never, { @@ -179,8 +163,8 @@ function rowSchemas(index: number) {
From decbed20bccc7e173adcbe6c624c26c33986d070 Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 23 Jul 2026 15:33:57 +0800 Subject: [PATCH 2/6] fix(@vben-core/form-ui): harden codec value boundaries --- .../ui-kit/form-ui/__tests__/form-api.test.ts | 66 +++++++++++++++++++ .../form-ui/__tests__/form-types.test.ts | 4 ++ packages/@core/ui-kit/form-ui/src/form-api.ts | 23 +++++-- packages/@core/ui-kit/form-ui/src/types.ts | 2 +- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index 0a150a65..deaa429f 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -3,6 +3,7 @@ import type { BaseFormComponentType } from '../src/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FormApi } from '../src/form-api'; +import { FormCodecError } from '../src/form-codec'; describe('formApi', () => { let formApi: FormApi; @@ -171,6 +172,71 @@ describe('formApi', () => { ); }); + it('should isolate codec results from live form values', async () => { + interface ProfileFormValues { + profile: { name: string }; + tags: string[]; + } + + const values: ProfileFormValues = { + profile: { name: 'Ada' }, + tags: ['admin'], + }; + const codecFormApi = new FormApi({ + codec: { + decode: (submitValues) => submitValues, + encode: (formValues) => ({ + profile: formValues.profile, + tags: formValues.tags, + }), + }, + }); + const formActions: any = { meta: {}, values }; + + codecFormApi.mount(formActions, new Map()); + const initialSubmissionValues = codecFormApi.getLatestSubmissionValues(); + values.profile.name = 'Grace'; + values.tags.push('user'); + + expect(initialSubmissionValues).toEqual({ + profile: { name: 'Ada' }, + tags: ['admin'], + }); + const submissionValues = await codecFormApi.getValues(); + expect(submissionValues.profile).not.toBe(values.profile); + expect(submissionValues.tags).not.toBe(values.tags); + }); + + it('should fall back to raw values when the initial codec encode fails', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const codecFormApi = new FormApi< + { name?: string }, + BaseFormComponentType, + Record, + { normalizedName: string } + >({ + codec: { + decode: (values) => ({ name: values.normalizedName }), + encode() { + throw new Error('incomplete initial values'); + }, + }, + }); + const formActions: any = { meta: {}, values: { name: 'Ada' } }; + + expect(() => codecFormApi.mount(formActions, new Map())).not.toThrow(); + + expect(codecFormApi.isMounted).toBe(true); + expect(codecFormApi.getLatestSubmissionValues()).toEqual({ name: 'Ada' }); + expect(warning).toHaveBeenCalledWith( + '[Vben Form] Failed to encode initial values. Falling back to raw form values.', + expect.objectContaining({ phase: 'encode' }), + ); + await expect(codecFormApi.getValues()).rejects.toBeInstanceOf( + FormCodecError, + ); + }); + it('should scan deprecated schema transforms once for unchanged state', async () => { const getChildren = vi.fn(() => []); const schema = { diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts index 430edeff..aee61d1e 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts @@ -228,6 +228,10 @@ describe('form public types', () => { handleReset(values) { expectTypeOf(values).toEqualTypeOf(); }, + handleSubmit(values, rawValues) { + expectTypeOf(values).toEqualTypeOf(); + expectTypeOf(rawValues).toEqualTypeOf>(); + }, schema: [], }); diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index 832d6a45..b47eb125 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -144,7 +144,7 @@ export class FormApi< if (this.state?.codec) { return encodeFormValues( this.state.codec, - rawValues as Readonly, + cloneDeep(toRaw(rawValues)) as Readonly, ); } return formatFormValues( @@ -303,10 +303,23 @@ export class FormApi< if (!this.isMounted) { this.form = formActions; this.stateHandler.setConditionTrue(); - const initialValues = this.form.values - ? this.formatValues(toRaw(this.form.values)) - : {}; - this.setLatestSubmissionValues(initialValues); + let initialValues: FormValues = {}; + if (this.form.values) { + const rawInitialValues = toRaw(this.form.values); + try { + initialValues = this.formatValues(rawInitialValues); + } catch (error) { + if (!this.state?.codec) { + throw error; + } + console.warn( + '[Vben Form] Failed to encode initial values. Falling back to raw form values.', + error, + ); + initialValues = cloneDeep(rawInitialValues); + } + } + this.setLatestSubmissionValues(initialValues as Partial); this.componentRefMap = componentRefMap ?? this.componentRefMap ?? new Map(); this.isMounted = true; diff --git a/packages/@core/ui-kit/form-ui/src/types.ts b/packages/@core/ui-kit/form-ui/src/types.ts index 99f5065c..9902c9c3 100644 --- a/packages/@core/ui-kit/form-ui/src/types.ts +++ b/packages/@core/ui-kit/form-ui/src/types.ts @@ -678,7 +678,7 @@ export type HandleSubmitFn< TFormValues extends FormValues = FormValues, TSubmitValues extends FormValues = TFormValues, > = ( - values: TSubmitValues, + values: NoInfer, rawValues: Readonly, ) => Promise | void; From 43b78da024ad2a31b1e5a676da54b516dd4cee33 Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 23 Jul 2026 15:39:53 +0800 Subject: [PATCH 3/6] refactor(@vben/playground): share date range codecs --- .../__tests__/unit/date-range-codec.test.ts | 47 ++++++++++++++ playground/src/utils/date-range-codec.ts | 64 +++++++++++++++++++ .../src/views/examples/vxe-table/form.vue | 31 +++------ playground/src/views/system/role/list.vue | 34 +++------- playground/src/views/system/user/list.vue | 34 +++------- 5 files changed, 135 insertions(+), 75 deletions(-) create mode 100644 playground/__tests__/unit/date-range-codec.test.ts create mode 100644 playground/src/utils/date-range-codec.ts diff --git a/playground/__tests__/unit/date-range-codec.test.ts b/playground/__tests__/unit/date-range-codec.test.ts new file mode 100644 index 00000000..589ffe30 --- /dev/null +++ b/playground/__tests__/unit/date-range-codec.test.ts @@ -0,0 +1,47 @@ +import type { Dayjs } from 'dayjs'; + +import dayjs from 'dayjs'; +import { describe, expect, it } from 'vitest'; + +import { createDateRangeCodec } from '../../src/utils/date-range-codec'; + +interface SearchFormValues extends Record { + createdAt?: [Dayjs, Dayjs]; + keyword?: string; +} + +const codec = createDateRangeCodec()({ + endField: 'finishedAt', + rangeField: 'createdAt', + startField: 'startedAt', +}); + +describe('date range codec', () => { + it('encodes and decodes configurable date range fields', () => { + const submitValues = codec.encode({ + createdAt: [dayjs('2026-07-01'), dayjs('2026-07-23')], + keyword: 'admin', + }); + + expect(submitValues).toEqual({ + finishedAt: '2026-07-23', + keyword: 'admin', + startedAt: '2026-07-01', + }); + const formValues = codec.decode(submitValues); + expect(formValues.keyword).toBe('admin'); + expect( + formValues.createdAt?.map((value) => value.format('YYYY-MM-DD')), + ).toEqual(['2026-07-01', '2026-07-23']); + }); + + it('does not reconstruct a range with a missing bound', () => { + expect( + codec.decode({ + finishedAt: undefined, + keyword: 'admin', + startedAt: '2026-07-01', + }), + ).toEqual({ keyword: 'admin' }); + }); +}); diff --git a/playground/src/utils/date-range-codec.ts b/playground/src/utils/date-range-codec.ts new file mode 100644 index 00000000..00243e7f --- /dev/null +++ b/playground/src/utils/date-range-codec.ts @@ -0,0 +1,64 @@ +import type { Dayjs } from 'dayjs'; + +import dayjs from 'dayjs'; + +type DateRange = [Dayjs, Dayjs]; + +type DateRangeSubmitValues< + TFormValues extends Record, + TRangeField extends keyof TFormValues, + TStartField extends string, + TEndField extends string, +> = Omit & + Record; + +interface DateRangeCodecOptions< + TFormValues extends Record, + TRangeField extends keyof TFormValues & string, + TStartField extends string, + TEndField extends string, +> { + endField: TEndField; + rangeField: TRangeField; + startField: TStartField; +} + +export function createDateRangeCodec< + TFormValues extends Record, +>() { + return function createCodec< + TRangeField extends keyof TFormValues & string, + TStartField extends string, + TEndField extends string, + >({ + endField, + rangeField, + startField, + }: DateRangeCodecOptions) { + type SubmitValues = DateRangeSubmitValues< + TFormValues, + TRangeField, + TStartField, + TEndField + >; + + return { + decode(values: Readonly): TFormValues { + const { [endField]: end, [startField]: start, ...formValues } = values; + return { + ...formValues, + ...(start && end ? { [rangeField]: [dayjs(start), dayjs(end)] } : {}), + } as TFormValues; + }, + encode(values: Readonly): SubmitValues { + const { [rangeField]: value, ...formValues } = values; + const range = value as DateRange | undefined; + return { + ...formValues, + [endField]: range?.[1]?.format('YYYY-MM-DD'), + [startField]: range?.[0]?.format('YYYY-MM-DD'), + } as SubmitValues; + }, + }; + }; +} diff --git a/playground/src/views/examples/vxe-table/form.vue b/playground/src/views/examples/vxe-table/form.vue index ed0645ba..f4d83c55 100644 --- a/playground/src/views/examples/vxe-table/form.vue +++ b/playground/src/views/examples/vxe-table/form.vue @@ -11,6 +11,7 @@ import dayjs from 'dayjs'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { getExampleTableApi } from '#/api'; +import { createDateRangeCodec } from '#/utils/date-range-codec'; interface RowType { category: string; @@ -29,32 +30,16 @@ interface SearchFormValues extends Record { productName?: string; } -function encodeSearchFormValues(values: Readonly) { - const { date, ...formValues } = values; - return { - ...formValues, - end: date?.[1]?.format('YYYY-MM-DD'), - start: date?.[0]?.format('YYYY-MM-DD'), - }; -} +const searchCodec = createDateRangeCodec()({ + endField: 'end', + rangeField: 'date', + startField: 'start', +}); -type SearchSubmitValues = ReturnType; - -function decodeSearchFormValues( - values: Readonly, -): SearchFormValues { - const { end, start, ...formValues } = values; - return { - ...formValues, - ...(start && end ? { date: [dayjs(start), dayjs(end)] } : {}), - }; -} +type SearchSubmitValues = ReturnType; const formOptions: VbenFormProps = { - codec: { - decode: decodeSearchFormValues, - encode: encodeSearchFormValues, - }, + codec: searchCodec, // 默认展开 collapsed: false, schema: [ diff --git a/playground/src/views/system/role/list.vue b/playground/src/views/system/role/list.vue index 3802753b..321eef7d 100644 --- a/playground/src/views/system/role/list.vue +++ b/playground/src/views/system/role/list.vue @@ -13,11 +13,11 @@ import { Page, useVbenDrawer } from '@vben/common-ui'; import { Plus } from '@vben/icons'; import { Button, message, Modal } from 'antdv-next'; -import dayjs from 'dayjs'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { deleteRole, getRoleList, updateRole } from '#/api'; import { $t } from '#/locales'; +import { createDateRangeCodec } from '#/utils/date-range-codec'; import { useColumns, useGridFormSchema } from './data'; import Form from './modules/form.vue'; @@ -26,28 +26,13 @@ interface RoleSearchFormValues extends Record { createTime?: [Dayjs, Dayjs]; } -function encodeRoleSearchValues(values: Readonly) { - const { createTime, ...formValues } = values; - return { - ...formValues, - endTime: createTime?.[1]?.format('YYYY-MM-DD'), - startTime: createTime?.[0]?.format('YYYY-MM-DD'), - }; -} +const roleSearchCodec = createDateRangeCodec()({ + endField: 'endTime', + rangeField: 'createTime', + startField: 'startTime', +}); -type RoleSearchSubmitValues = ReturnType; - -function decodeRoleSearchValues( - values: Readonly, -): RoleSearchFormValues { - const { endTime, startTime, ...formValues } = values; - return { - ...formValues, - ...(startTime && endTime - ? { createTime: [dayjs(startTime), dayjs(endTime)] } - : {}), - }; -} +type RoleSearchSubmitValues = ReturnType; const [FormDrawer, formDrawerApi] = useVbenDrawer({ connectedComponent: Form, @@ -56,10 +41,7 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({ const [Grid, gridApi] = useVbenVxeGrid({ formOptions: { - codec: { - decode: decodeRoleSearchValues, - encode: encodeRoleSearchValues, - }, + codec: roleSearchCodec, schema: useGridFormSchema(), submitOnChange: true, }, diff --git a/playground/src/views/system/user/list.vue b/playground/src/views/system/user/list.vue index a73b4bc0..d4b65dea 100644 --- a/playground/src/views/system/user/list.vue +++ b/playground/src/views/system/user/list.vue @@ -12,11 +12,11 @@ import { Page, Tree, useVbenDrawer } from '@vben/common-ui'; import { Plus } from '@vben/icons'; import { Button, Card, InputSearch, message, Modal } from 'antdv-next'; -import dayjs from 'dayjs'; import { useVbenVxeGrid, VbenTableAction } from '#/adapter/vxe-table'; import { deleteUser, getDeptList, getUserList, updateUser } from '#/api'; import { $t } from '#/locales'; +import { createDateRangeCodec } from '#/utils/date-range-codec'; import { useColumns, useGridFormSchema } from './data'; import Detail from './modules/detail.vue'; @@ -26,28 +26,13 @@ interface UserSearchFormValues extends Record { createTime?: [Dayjs, Dayjs]; } -function encodeUserSearchValues(values: Readonly) { - const { createTime, ...formValues } = values; - return { - ...formValues, - endTime: createTime?.[1]?.format('YYYY-MM-DD'), - startTime: createTime?.[0]?.format('YYYY-MM-DD'), - }; -} +const userSearchCodec = createDateRangeCodec()({ + endField: 'endTime', + rangeField: 'createTime', + startField: 'startTime', +}); -type UserSearchSubmitValues = ReturnType; - -function decodeUserSearchValues( - values: Readonly, -): UserSearchFormValues { - const { endTime, startTime, ...formValues } = values; - return { - ...formValues, - ...(startTime && endTime - ? { createTime: [dayjs(startTime), dayjs(endTime)] } - : {}), - }; -} +type UserSearchSubmitValues = ReturnType; const deptList = ref([]); const inputSearchValue = ref(''); @@ -65,10 +50,7 @@ const [DetailDrawer, detailDrawerApi] = useVbenDrawer({ const [Grid, gridApi] = useVbenVxeGrid({ formOptions: { - codec: { - decode: decodeUserSearchValues, - encode: encodeUserSearchValues, - }, + codec: userSearchCodec, schema: useGridFormSchema(), submitOnChange: true, }, From 2e69c3fefc51b340c95ea03d0f4f8d58a3f0b2b5 Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 23 Jul 2026 15:47:08 +0800 Subject: [PATCH 4/6] fix(@vben/playground): address form codec edge cases --- playground/src/views/demos/form-array/index.vue | 2 +- playground/src/views/system/menu/modules/form.vue | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/playground/src/views/demos/form-array/index.vue b/playground/src/views/demos/form-array/index.vue index 536508c3..702c1960 100644 --- a/playground/src/views/demos/form-array/index.vue +++ b/playground/src/views/demos/form-array/index.vue @@ -121,7 +121,7 @@ const schema: VbenFormSchema[] = [ defaultValue: '', fieldName: 'name', label: '姓名', - rules: z.string().min(1, '请输入姓名'), + rules: z.string().trim().min(1, '请输入姓名'), }, { component: 'Select', diff --git a/playground/src/views/system/menu/modules/form.vue b/playground/src/views/system/menu/modules/form.vue index 8129e83d..7ce50f05 100644 --- a/playground/src/views/system/menu/modules/form.vue +++ b/playground/src/views/system/menu/modules/form.vue @@ -40,15 +40,19 @@ function encodeMenuFormValues( ): MenuSubmitValues { const { linkSrc, ...formValues } = values; if (values.type === 'link') { + const meta = { ...values.meta, link: linkSrc }; + Reflect.deleteProperty(meta, 'iframeSrc'); return { ...formValues, - meta: { ...values.meta, link: linkSrc }, + meta, }; } if (values.type === 'embedded') { + const meta = { ...values.meta, iframeSrc: linkSrc }; + Reflect.deleteProperty(meta, 'link'); return { ...formValues, - meta: { ...values.meta, iframeSrc: linkSrc }, + meta, }; } return formValues; From bacfdf5e42c97c27e580fa4206dd75a22c278f0d Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Thu, 23 Jul 2026 16:22:15 +0800 Subject: [PATCH 5/6] perf(@vben-core/form-ui): optimize array field updates --- package.json | 1 + .../__tests__/form-integration.test.ts | 48 ++++++ .../__tests__/form-performance.benchmark.ts | 154 ++++++++++++++++++ .../src/components/form-field-array.vue | 26 +-- .../form-ui/src/form-render/form-field.vue | 3 +- 5 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts diff --git a/package.json b/package.json index 7496acf9..7698e7c4 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "preview": "turbo-run preview", "publint": "vsh publint", "reinstall": "pnpm run clean -- --del-lock && pnpm install", + "test:benchmark": "vitest bench --run", "test:unit": "vitest run --dom", "test:e2e": "turbo run test:e2e", "update:deps": "npx taze -r -w", diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts index 63c64e21..24d5c8a3 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts @@ -513,9 +513,57 @@ describe('useVbenForm integration', () => { await flushPromises(); expect(wrapper.get('input').element).toBe(inputElement); + expect((input.element as HTMLInputElement).value).toBe('Ada Lovelace'); expect(document.activeElement).toBe(inputElement); }); + it('updates optimized array rows when values and schemas change', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + { + children: [ + { + component: TestInput, + fieldName: 'name', + label: 'Name', + }, + ], + defaultValue: [{ name: 'Ada' }, { name: 'Grace' }], + fieldName: 'contacts', + type: 'array', + }, + ], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + const firstInput = wrapper.get('input[name="contacts[0].name"]'); + const firstInputElement = firstInput.element; + + await formApi.setFieldValue('contacts[0].name', 'Ada Lovelace'); + await flushPromises(); + + expect(wrapper.get('input[name="contacts[0].name"]').element).toBe( + firstInputElement, + ); + expect(firstInput.element.getAttribute('value')).toBe('Ada Lovelace'); + + formApi.updateSchema([ + { + componentProps: { disabled: true }, + fieldName: 'contacts.name', + }, + ]); + await flushPromises(); + + expect( + wrapper.get('input[name="contacts[0].name"]').attributes(), + ).toHaveProperty('disabled'); + expect( + wrapper.get('input[name="contacts[1].name"]').attributes(), + ).toHaveProperty('disabled'); + }); + it('scopes resolve dependencies to array rows', async () => { const resolve = vi.fn(({ schema }: Record) => ({ componentProps: { diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts b/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts new file mode 100644 index 00000000..867e3f1b --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts @@ -0,0 +1,154 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; + +import { afterAll, bench, describe } from 'vitest'; + +import { setupVbenForm } from '../src/config'; +import { FormApi } from '../src/form-api'; +import { encodeFormValues } from '../src/form-codec'; +import { useVbenForm } from '../src/use-vben-form'; + +interface ContactValues { + enabled: boolean; + metadata: { + permissions: string[]; + team: string; + }; + name: string; + phone: string; + tags: string[]; +} + +interface PerformanceFormValues extends Record { + contacts: ContactValues[]; + settings: { + alerts: boolean; + locale: string; + sections: string[]; + }; +} + +const ROW_COUNT = 100; + +const TestInput = defineComponent({ + inheritAttrs: false, + emits: ['update:modelValue'], + setup(_props, { attrs, emit }) { + function handleInput(event: Event) { + const target = event.target; + if (target instanceof HTMLInputElement) { + emit('update:modelValue', target.value); + } + } + + return () => + h('input', { + ...attrs, + onInput: handleInput, + value: attrs.modelValue ?? '', + }); + }, +}); + +function createFormValues(): PerformanceFormValues { + return { + contacts: Array.from({ length: ROW_COUNT }, (_, index) => ({ + enabled: index % 2 === 0, + metadata: { + permissions: ['read', 'write', 'review'], + team: `team-${index % 10}`, + }, + name: ` Contact ${index} `, + phone: `10086-${index}`, + tags: ['primary', 'on-call', `group-${index % 5}`], + })), + settings: { + alerts: true, + locale: 'zh-CN', + sections: ['profile', 'security', 'notifications'], + }, + }; +} + +const codec = { + decode: (values: Readonly) => ({ ...values }), + encode: (values: Readonly) => ({ + ...values, + contacts: values.contacts.map((contact) => ({ + ...contact, + name: contact.name.trim(), + })), + }), +}; + +const formValues = createFormValues(); +const codecFormApi = new FormApi({ codec }); +codecFormApi.mount({ meta: {}, values: formValues } as never, new Map()); + +setupVbenForm({ config: {}, rules: {} }); +const [ArrayForm, arrayFormApi] = useVbenForm({ + schema: [ + { + children: [ + { + component: TestInput, + fieldName: 'name', + label: 'Name', + }, + ], + defaultValue: Array.from({ length: ROW_COUNT }, (_, index) => ({ + name: `Contact ${index}`, + })), + fieldName: 'contacts', + type: 'array', + }, + ], +}); +const arrayWrapper = mount(ArrayForm); +await flushPromises(); +let arrayEditIteration = 0; + +afterAll(() => { + arrayWrapper.unmount(); +}); + +describe('form codec performance', () => { + bench( + 'encode 100 nested rows without isolation', + () => { + encodeFormValues(codec, formValues); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'encode 100 nested rows with isolated input', + () => { + codecFormApi.formatValues(formValues); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'create submit snapshot for 100 nested rows', + async () => { + await codecFormApi.getValueSnapshot(); + }, + { time: 1000, warmupTime: 200 }, + ); +}); + +describe('form array performance', () => { + bench( + 'edit one field in a 100-row array', + async () => { + arrayEditIteration += 1; + await arrayFormApi.setFieldValue( + 'contacts[50].name', + `Contact ${arrayEditIteration}`, + ); + await nextTick(); + }, + { time: 1000, warmupTime: 200 }, + ); +}); diff --git a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue index ca9a8796..9f22067e 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue @@ -10,7 +10,7 @@ import { VbenIconButton, VbenRenderContent, } from '@vben-core/shadcn-ui'; -import { cn, set } from '@vben-core/shared/utils'; +import { cn, get, set } from '@vben-core/shared/utils'; import { injectRenderFormProps } from '../form-render/context'; import FormField from '../form-render/form-field.vue'; @@ -71,14 +71,16 @@ if (!form) { throw new Error('Form api is required in '); } const formActions = form; -const arrayValue = formActions.useFieldValue(props.name); - -const fields = computed[]>(() => { - return Array.isArray(arrayValue.value) ? arrayValue.value : []; +const arrayLength = formActions.useSelector((state) => { + const value = get(state.values, props.name); + return Array.isArray(value) ? value.length : 0; }); +const rowIndexes = computed(() => + Array.from({ length: arrayLength.value }, (_, index) => index), +); -const canAdd = computed(() => fields.value.length < props.max); -const canRemove = computed(() => fields.value.length > props.min); +const canAdd = computed(() => arrayLength.value < props.max); +const canRemove = computed(() => arrayLength.value > props.min); const gridStyle = computed(() => { const columns = [ ...(props.showIndex ? ['3rem'] : []), @@ -133,6 +135,10 @@ function rowSchemas(index: number) { }), ); } + +const normalizedRowSchemas = computed(() => + Array.from({ length: arrayLength.value }, (_, index) => rowSchemas(index)), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a51b710f..d31b203f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3 + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.0 '@tanstack/vue-form': specifier: ^1.33.2 version: 1.33.2 @@ -1400,6 +1403,9 @@ importers: packages/@core/ui-kit/form-ui: dependencies: + '@tanstack/store': + specifier: 'catalog:' + version: 0.11.0 '@tanstack/vue-form': specifier: 'catalog:' version: 1.33.2(vue@3.5.40(typescript@6.0.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fce71719..1770501c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,6 +62,7 @@ catalog: '@stylistic/stylelint-plugin': ^5.2.1 '@tailwindcss/typography': ^0.5.20 '@tailwindcss/vite': ^4.3.3 + '@tanstack/store': ^0.11.0 '@tanstack/vue-form': ^1.33.2 '@tanstack/vue-query': ^5.101.2 '@tanstack/vue-store': ^0.11.0