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] 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;