fix(@vben-core/form-ui): harden codec value boundaries

This commit is contained in:
Dream
2026-07-23 15:33:57 +08:00
parent 1ac5ac3e82
commit decbed20bc
4 changed files with 89 additions and 6 deletions

View File

@@ -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<ProfileFormValues>({
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<never, never>,
{ 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 = {

View File

@@ -228,6 +228,10 @@ describe('form public types', () => {
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
schema: [],
});

View File

@@ -144,7 +144,7 @@ export class FormApi<
if (this.state?.codec) {
return encodeFormValues(
this.state.codec,
rawValues as Readonly<TFormValues>,
cloneDeep(toRaw(rawValues)) as Readonly<TFormValues>,
);
}
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<TSubmitValues>);
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;

View File

@@ -678,7 +678,7 @@ export type HandleSubmitFn<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = (
values: TSubmitValues,
values: NoInfer<TSubmitValues>,
rawValues: Readonly<TFormValues>,
) => Promise<void> | void;