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)), +);