perf(@vben-core/form-ui): benchmark form hot paths
This commit is contained in:
@@ -0,0 +1,207 @@
|
|||||||
|
import type { FormSchema } from '../src/types';
|
||||||
|
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, h, nextTick } from 'vue';
|
||||||
|
|
||||||
|
import { afterAll, bench, describe } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { setupVbenForm } from '../src/config';
|
||||||
|
import { useVbenForm } from '../src/use-vben-form';
|
||||||
|
|
||||||
|
const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const;
|
||||||
|
const FIELD_COUNT = 100;
|
||||||
|
const MOUNT_FIELD_COUNT = 50;
|
||||||
|
|
||||||
|
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 createFlatSchema(
|
||||||
|
fieldCount: number,
|
||||||
|
withRules: boolean = false,
|
||||||
|
): FormSchema[] {
|
||||||
|
const rule = withRules ? z.string().min(1) : undefined;
|
||||||
|
return Array.from({ length: fieldCount }, (_, index) => ({
|
||||||
|
component: TestInput,
|
||||||
|
defaultValue: `Value ${index}`,
|
||||||
|
fieldName: `field${index}`,
|
||||||
|
label: `Field ${index}`,
|
||||||
|
rules: rule,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFlatValues(prefix: string) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from({ length: FIELD_COUNT }, (_, index) => [
|
||||||
|
`field${index}`,
|
||||||
|
`${prefix} ${index}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupVbenForm({ config: {}, rules: {} });
|
||||||
|
|
||||||
|
const flatSchema = createFlatSchema(FIELD_COUNT);
|
||||||
|
const [FlatForm, flatFormApi] = useVbenForm<Record<string, string>>({
|
||||||
|
schema: flatSchema,
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
const flatWrapper = mount(FlatForm);
|
||||||
|
|
||||||
|
const [ValidationForm, validationFormApi] = useVbenForm<Record<string, string>>(
|
||||||
|
{
|
||||||
|
schema: createFlatSchema(FIELD_COUNT, true),
|
||||||
|
showDefaultActions: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const validationWrapper = mount(ValidationForm);
|
||||||
|
|
||||||
|
const dependencySchema: FormSchema[] = [
|
||||||
|
{
|
||||||
|
component: TestInput,
|
||||||
|
defaultValue: 'editable',
|
||||||
|
fieldName: 'mode',
|
||||||
|
label: 'Mode',
|
||||||
|
},
|
||||||
|
...Array.from({ length: 50 }, (_, index) => ({
|
||||||
|
component: TestInput,
|
||||||
|
defaultValue: `Value ${index}`,
|
||||||
|
dependencies: {
|
||||||
|
resolve: ({ values }) => ({ disabled: values.mode === 'locked' }),
|
||||||
|
triggerFields: ['mode'],
|
||||||
|
},
|
||||||
|
fieldName: `dependent${index}`,
|
||||||
|
label: `Dependent ${index}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const [DependencyForm, dependencyFormApi] = useVbenForm<Record<string, string>>(
|
||||||
|
{
|
||||||
|
schema: dependencySchema,
|
||||||
|
showDefaultActions: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const dependencyWrapper = mount(DependencyForm);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const batchValues = [createFlatValues('Alpha'), createFlatValues('Beta')];
|
||||||
|
const schemaPatches = [false, true].map((disabled) =>
|
||||||
|
Array.from({ length: FIELD_COUNT }, (_, index) => ({
|
||||||
|
componentProps: { disabled },
|
||||||
|
fieldName: `field${index}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let batchIteration = 0;
|
||||||
|
let dependencyIteration = 0;
|
||||||
|
let fieldIteration = 0;
|
||||||
|
let resetIteration = 0;
|
||||||
|
let schemaIteration = 0;
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
dependencyWrapper.unmount();
|
||||||
|
flatWrapper.unmount();
|
||||||
|
validationWrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form render performance', () => {
|
||||||
|
bench(
|
||||||
|
'initialize, mount, and unmount 50 fields',
|
||||||
|
async () => {
|
||||||
|
const [Form] = useVbenForm<Record<string, string>>({
|
||||||
|
schema: createFlatSchema(MOUNT_FIELD_COUNT),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
const wrapper = mount(Form);
|
||||||
|
await flushPromises();
|
||||||
|
wrapper.unmount();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form value performance', () => {
|
||||||
|
bench(
|
||||||
|
'update one field in a 100-field form',
|
||||||
|
async () => {
|
||||||
|
fieldIteration += 1;
|
||||||
|
await flatFormApi.setFieldValue('field50', `Value ${fieldIteration}`);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'set 100 fields in one batch',
|
||||||
|
async () => {
|
||||||
|
batchIteration += 1;
|
||||||
|
await flatFormApi.setValues(batchValues[batchIteration % 2] ?? {});
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'reset 100 fields to alternate values',
|
||||||
|
async () => {
|
||||||
|
resetIteration += 1;
|
||||||
|
await flatFormApi.reset(
|
||||||
|
{ values: batchValues[resetIteration % 2] ?? {} },
|
||||||
|
{ force: true },
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form validation performance', () => {
|
||||||
|
bench(
|
||||||
|
'validate 100 fields with zod rules',
|
||||||
|
async () => {
|
||||||
|
await validationFormApi.validate();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form schema performance', () => {
|
||||||
|
bench(
|
||||||
|
'update 100 schema entries',
|
||||||
|
async () => {
|
||||||
|
schemaIteration += 1;
|
||||||
|
flatFormApi.updateSchema(schemaPatches[schemaIteration % 2] ?? []);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'resolve 50 dependencies from one trigger',
|
||||||
|
async () => {
|
||||||
|
dependencyIteration += 1;
|
||||||
|
await dependencyFormApi.setFieldValue(
|
||||||
|
'mode',
|
||||||
|
dependencyIteration % 2 === 0 ? 'editable' : 'locked',
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
},
|
||||||
|
BENCHMARK_OPTIONS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -104,6 +104,34 @@ describe('useVbenForm integration', () => {
|
|||||||
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1);
|
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps values reactive when exposed through the default slot', async () => {
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: TestInput,
|
||||||
|
defaultValue: 'Ada',
|
||||||
|
fieldName: 'name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
const wrapper = mount(Form, {
|
||||||
|
slots: {
|
||||||
|
default: ({ values }: { values: Record<string, any> }) =>
|
||||||
|
h('span', { class: 'slot-value' }, values.name),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
wrappers.push(wrapper);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('.slot-value').text()).toBe('Ada');
|
||||||
|
|
||||||
|
await formApi.setFieldValue('name', 'Grace');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('.slot-value').text()).toBe('Grace');
|
||||||
|
});
|
||||||
|
|
||||||
it('supports a field-level change event fallback for legacy components', async () => {
|
it('supports a field-level change event fallback for legacy components', async () => {
|
||||||
const [Form, formApi] = useVbenForm({
|
const [Form, formApi] = useVbenForm({
|
||||||
schema: [
|
schema: [
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ const [ArrayForm, arrayFormApi] = useVbenForm({
|
|||||||
});
|
});
|
||||||
const arrayWrapper = mount(ArrayForm);
|
const arrayWrapper = mount(ArrayForm);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
const arraySchemaPatches = [false, true].map((disabled) => ({
|
||||||
|
componentProps: { disabled },
|
||||||
|
fieldName: 'contacts.name',
|
||||||
|
}));
|
||||||
let arrayEditIteration = 0;
|
let arrayEditIteration = 0;
|
||||||
|
let arraySchemaIteration = 0;
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
arrayWrapper.unmount();
|
arrayWrapper.unmount();
|
||||||
@@ -151,4 +156,27 @@ describe('form array performance', () => {
|
|||||||
},
|
},
|
||||||
{ time: 1000, warmupTime: 200 },
|
{ time: 1000, warmupTime: 200 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'append and remove one row from a 100-row array',
|
||||||
|
async () => {
|
||||||
|
arrayFormApi.form.pushFieldValue('contacts', { name: 'Temporary' });
|
||||||
|
await nextTick();
|
||||||
|
await arrayFormApi.form.removeFieldValue('contacts', ROW_COUNT);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
{ time: 1000, warmupTime: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
bench(
|
||||||
|
'update one child schema across 100 rows',
|
||||||
|
async () => {
|
||||||
|
arraySchemaIteration += 1;
|
||||||
|
arrayFormApi.updateSchema([
|
||||||
|
arraySchemaPatches[arraySchemaIteration % 2] ?? {},
|
||||||
|
]);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
{ time: 1000, warmupTime: 200 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/store": "catalog:",
|
||||||
"@tanstack/vue-form": "catalog:",
|
"@tanstack/vue-form": "catalog:",
|
||||||
"@vben-core/composables": "workspace:*",
|
"@vben-core/composables": "workspace:*",
|
||||||
"@vben-core/icons": "workspace:*",
|
"@vben-core/icons": "workspace:*",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { computed, shallowRef } from 'vue';
|
|||||||
|
|
||||||
import { mergeWithArrayOverride } from '@vben-core/shared/utils';
|
import { mergeWithArrayOverride } from '@vben-core/shared/utils';
|
||||||
|
|
||||||
|
import { batch } from '@tanstack/store';
|
||||||
import { useForm } from '@tanstack/vue-form';
|
import { useForm } from '@tanstack/vue-form';
|
||||||
|
|
||||||
import { createRuntimeFieldComponent } from './form-runtime-field';
|
import { createRuntimeFieldComponent } from './form-runtime-field';
|
||||||
@@ -289,11 +290,13 @@ export function useFormRuntime<TValues extends FormValues>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setValues(values, shouldValidate) {
|
async setValues(values, shouldValidate) {
|
||||||
for (const [fieldName, value] of Object.entries(values)) {
|
batch(() => {
|
||||||
rawForm.setFieldValue(fieldName as never, value as never, {
|
for (const [fieldName, value] of Object.entries(values)) {
|
||||||
dontValidate: !shouldValidate,
|
rawForm.setFieldValue(fieldName as never, value as never, {
|
||||||
});
|
dontValidate: !shouldValidate,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
if (shouldValidate) {
|
if (shouldValidate) {
|
||||||
await validate();
|
await validate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,13 @@ watch(values, (currentValues, previousValues) => {
|
|||||||
if (!valuesChangeReady) {
|
if (!valuesChangeReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const handleValuesChange = forward.value.handleValuesChange;
|
||||||
|
const submitOnChange = state?.value.submitOnChange;
|
||||||
|
if (!handleValuesChange && !submitOnChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
|
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
|
||||||
if (forward.value.handleValuesChange && fields.length > 0) {
|
if (handleValuesChange && fields.length > 0) {
|
||||||
const changedFields = fields.filter((field) => {
|
const changedFields = fields.filter((field) => {
|
||||||
return !isEqual(
|
return !isEqual(
|
||||||
get(currentValues, field),
|
get(currentValues, field),
|
||||||
@@ -96,14 +101,14 @@ watch(values, (currentValues, previousValues) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (changedFields.length > 0) {
|
if (changedFields.length > 0) {
|
||||||
forward.value.handleValuesChange(
|
handleValuesChange(readonly(currentValues), changedFields, () =>
|
||||||
readonly(currentValues),
|
formApi.formatValues(currentValues),
|
||||||
changedFields,
|
|
||||||
() => formApi.formatValues(currentValues),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleValuesChangeDebounced();
|
if (submitOnChange) {
|
||||||
|
handleValuesChangeDebounced();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -130,46 +135,50 @@ watch(values, (currentValues, previousValues) => {
|
|||||||
></slot>
|
></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #default="slotProps">
|
<template #default="slotProps">
|
||||||
<slot v-bind="slotProps" :form-api="formApi" :values="form.values">
|
<slot
|
||||||
<FormActions
|
v-if="$slots.default"
|
||||||
v-if="forward.showDefaultActions"
|
v-bind="slotProps"
|
||||||
:model-value="state?.collapsed"
|
:form-api="formApi"
|
||||||
@update:model-value="handleUpdateCollapsed"
|
:values="form.values"
|
||||||
>
|
></slot>
|
||||||
<template #reset-before="resetSlotProps">
|
<FormActions
|
||||||
<slot
|
v-else-if="forward.showDefaultActions"
|
||||||
name="reset-before"
|
:model-value="state?.collapsed"
|
||||||
v-bind="resetSlotProps"
|
@update:model-value="handleUpdateCollapsed"
|
||||||
:form-api="formApi"
|
>
|
||||||
:values="form.values"
|
<template #reset-before="resetSlotProps">
|
||||||
></slot>
|
<slot
|
||||||
</template>
|
name="reset-before"
|
||||||
<template #submit-before="submitSlotProps">
|
v-bind="resetSlotProps"
|
||||||
<slot
|
:form-api="formApi"
|
||||||
name="submit-before"
|
:values="form.values"
|
||||||
v-bind="submitSlotProps"
|
></slot>
|
||||||
:form-api="formApi"
|
</template>
|
||||||
:values="form.values"
|
<template #submit-before="submitSlotProps">
|
||||||
></slot>
|
<slot
|
||||||
</template>
|
name="submit-before"
|
||||||
<template #expand-before="expandBeforeSlotProps">
|
v-bind="submitSlotProps"
|
||||||
<slot
|
:form-api="formApi"
|
||||||
name="expand-before"
|
:values="form.values"
|
||||||
v-bind="expandBeforeSlotProps"
|
></slot>
|
||||||
:form-api="formApi"
|
</template>
|
||||||
:values="form.values"
|
<template #expand-before="expandBeforeSlotProps">
|
||||||
></slot>
|
<slot
|
||||||
</template>
|
name="expand-before"
|
||||||
<template #expand-after="expandAfterSlotProps">
|
v-bind="expandBeforeSlotProps"
|
||||||
<slot
|
:form-api="formApi"
|
||||||
name="expand-after"
|
:values="form.values"
|
||||||
v-bind="expandAfterSlotProps"
|
></slot>
|
||||||
:form-api="formApi"
|
</template>
|
||||||
:values="form.values"
|
<template #expand-after="expandAfterSlotProps">
|
||||||
></slot>
|
<slot
|
||||||
</template>
|
name="expand-after"
|
||||||
</FormActions>
|
v-bind="expandAfterSlotProps"
|
||||||
</slot>
|
:form-api="formApi"
|
||||||
|
:values="form.values"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
</FormActions>
|
||||||
</template>
|
</template>
|
||||||
</Form>
|
</Form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ catalogs:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
|
'@tanstack/store':
|
||||||
|
specifier: ^0.11.0
|
||||||
|
version: 0.11.0
|
||||||
'@tanstack/vue-form':
|
'@tanstack/vue-form':
|
||||||
specifier: ^1.33.2
|
specifier: ^1.33.2
|
||||||
version: 1.33.2
|
version: 1.33.2
|
||||||
@@ -1400,6 +1403,9 @@ importers:
|
|||||||
|
|
||||||
packages/@core/ui-kit/form-ui:
|
packages/@core/ui-kit/form-ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tanstack/store':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 0.11.0
|
||||||
'@tanstack/vue-form':
|
'@tanstack/vue-form':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.33.2(vue@3.5.40(typescript@6.0.3))
|
version: 1.33.2(vue@3.5.40(typescript@6.0.3))
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ catalog:
|
|||||||
'@stylistic/stylelint-plugin': ^5.2.1
|
'@stylistic/stylelint-plugin': ^5.2.1
|
||||||
'@tailwindcss/typography': ^0.5.20
|
'@tailwindcss/typography': ^0.5.20
|
||||||
'@tailwindcss/vite': ^4.3.3
|
'@tailwindcss/vite': ^4.3.3
|
||||||
|
'@tanstack/store': ^0.11.0
|
||||||
'@tanstack/vue-form': ^1.33.2
|
'@tanstack/vue-form': ^1.33.2
|
||||||
'@tanstack/vue-query': ^5.101.2
|
'@tanstack/vue-query': ^5.101.2
|
||||||
'@tanstack/vue-store': ^0.11.0
|
'@tanstack/vue-store': ^0.11.0
|
||||||
|
|||||||
Reference in New Issue
Block a user