From 489a1143b7e91d9b8d2e78f4ea4f5743bbb1f828 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Sun, 20 Sep 2026 16:50:55 +0800 Subject: [PATCH] fix(form): resolve function options and type for typeMatch validation Add formValues alias to filter callbacks. Prevent unhandled rejections during sync validation with async configs. --- packages/form-schema/src/base.ts | 2 + packages/form/src/utils/form.ts | 5 +- packages/form/src/utils/typeMatch.ts | 65 ++++++-- packages/form/tests/unit/utils/form.spec.ts | 2 + .../form/tests/unit/utils/typeMatch.spec.ts | 147 +++++++++++++++++- 5 files changed, 199 insertions(+), 22 deletions(-) diff --git a/packages/form-schema/src/base.ts b/packages/form-schema/src/base.ts index 0cb53547..20b7d76e 100644 --- a/packages/form-schema/src/base.ts +++ b/packages/form-schema/src/base.ts @@ -253,6 +253,8 @@ export type FilterFunction = ( values: FormValue; parent?: FormValue; formValue: FormValue; + /** 与 `formValue` 相同,对齐 Select.vue / SelectOptionFunction 的 `formValues` 别名 */ + formValues: FormValue; prop: string; config: any; index?: number; diff --git a/packages/form/src/utils/form.ts b/packages/form/src/utils/form.ts index 14bfa79f..b9267d37 100644 --- a/packages/form/src/utils/form.ts +++ b/packages/form/src/utils/form.ts @@ -368,11 +368,14 @@ export const filterFunction = ( props: any, ) => { if (typeof config === 'function') { + const formValue = readonly(mForm?.values || props.model); return (config as FilterFunction)(mForm, { values: readonly(mForm?.initValues || {}), model: readonly(props.model), parent: readonly(mForm?.parentValues || {}), - formValue: readonly(mForm?.values || props.model), + formValue, + // Select.vue / SelectOptionFunction 使用 formValues 别名 + formValues: formValue, prop: props.prop, config: props.config, index: props.index, diff --git a/packages/form/src/utils/typeMatch.ts b/packages/form/src/utils/typeMatch.ts index b681baaf..0736dd4f 100644 --- a/packages/form/src/utils/typeMatch.ts +++ b/packages/form/src/utils/typeMatch.ts @@ -56,6 +56,18 @@ const builtInTypeMatchRules = new Map(); const isPromise = (value: any): value is Promise => typeof value === 'object' && value !== null && typeof value.then === 'function'; +/** + * 同步校验无法等待 Promise。调用方会丢掉返回值,这里挂上空 handler,避免变成未捕获 rejection。 + */ +const ignorePromise = (value: any): value is Promise => { + if (!isPromise(value)) return false; + value.then( + () => {}, + () => {}, + ); + return true; +}; + /** 注册或覆盖某个字段 type 的 typeMatch 校验规则。`builtIn` 登记不受 `clearTypeMatchRules` / `deleteTypeMatchRule` 影响。 */ export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator, builtIn = false): void => { (builtIn ? builtInTypeMatchRules : extraTypeMatchRules).set(toLine(type), validator); @@ -89,11 +101,14 @@ const resolveConfig = ( props: any, ): T | undefined => { if (typeof config === 'function') { + const formValue = readonly(mForm?.values || props.model); return (config as Function)(mForm, { values: readonly(mForm?.initValues || {}), model: readonly(props.model), parent: readonly(mForm?.parentValues || {}), - formValue: readonly(mForm?.values || props.model), + formValue, + // Select.vue / SelectOptionFunction 使用 formValues 别名 + formValues: formValue, prop: props.prop, config: props.config, index: props.index, @@ -200,7 +215,7 @@ const resolveFieldDefaultValue = (mForm: FormState | undefined, props: any): any if (typeof defaultValue === 'undefined' || defaultValue === 'undefined') return undefined; const resolvedDefaultValue = resolveConfig(mForm, defaultValue, props); // resolveConfig 返回 Promise(如 defaultValue 为异步函数)时无法同步获取默认值,回退到通用示例 - if (isPromise(resolvedDefaultValue)) { + if (ignorePromise(resolvedDefaultValue)) { return undefined; } return resolvedDefaultValue; @@ -331,9 +346,21 @@ const flattenSelectOptions = (options: any[]): any[] => { return values; }; -const resolveOptions = (props: any): any[] => { - const { options } = props.config || {}; - return Array.isArray(options) ? options : []; +/** + * 解析字段 options:静态数组原样返回;函数型与 `display` / `type` 一样当场求值。 + * + * 函数返回 Promise、非数组或抛错时当作「还没有可选项」,交由调用方按空 options 跳过枚举。 + * `remote` / `allowCreate` 仍由 `validateSelectValue` 单独放行,不在这里区分。 + */ +const resolveOptions = (mForm: FormState | undefined, props: any): any[] => { + let resolved: unknown; + try { + resolved = resolveConfig(mForm, props.config?.options, props); + } catch { + return []; + } + if (ignorePromise(resolved) || !Array.isArray(resolved)) return []; + return resolved; }; const includesOptionValue = (optionValues: any[], value: any) => optionValues.some((item) => Object.is(item, value)); @@ -440,9 +467,6 @@ const validateSelectValue = ( return undefined; } - // 仅当 options 为静态数组时才校验值是否在可选项中,动态 options(函数形式)跳过 - const isStaticOptions = Array.isArray(config.options); - if (config.multiple) { if (!Array.isArray(value)) { return defaultMessage( @@ -451,13 +475,13 @@ const validateSelectValue = ( optionExampleSuggestion(optionValues, '["选项1", "选项2"]', true), ); } - if (isStaticOptions && value.some((item) => !includesOptionValue(optionValues, item))) { + if (value.some((item) => !includesOptionValue(optionValues, item))) { return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues)); } return undefined; } - if (isStaticOptions && !includesOptionValue(optionValues, value)) { + if (!includesOptionValue(optionValues, value)) { return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues)); } return undefined; @@ -470,7 +494,7 @@ const validateCascaderValue = ( mForm: FormState | undefined, message: string | undefined, ): string | undefined => { - const options = resolveOptions(props) as CascaderOption[]; + const options = resolveOptions(mForm, props) as CascaderOption[]; if (!options.length) { return; @@ -478,7 +502,7 @@ const validateCascaderValue = ( const valueSeparator = resolveConfig(mForm, config.valueSeparator, props); // resolveConfig 返回 Promise(如 valueSeparator 为异步函数)时无法同步确定分隔符,跳过校验 - if (isPromise(valueSeparator)) { + if (ignorePromise(valueSeparator)) { return undefined; } const emitPath = config.emitPath !== false; @@ -631,12 +655,12 @@ const validateBuiltinTypeMatch = ( } if (fieldType === 'select') { - const optionValues = flattenSelectOptions(resolveOptions(props)); + const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); return validateSelectValue(value, config, optionValues, message); } if (fieldType === 'radio-group') { - const optionValues = flattenSelectOptions(resolveOptions(props)); + const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); if (optionValues.length === 0) { return undefined; @@ -649,7 +673,7 @@ const validateBuiltinTypeMatch = ( } if (fieldType === 'checkbox-group') { - const optionValues = flattenSelectOptions(resolveOptions(props)); + const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); if (optionValues.length === 0) { return undefined; @@ -727,12 +751,19 @@ export const validateTypeMatch = ( } const rawFieldType = 'type' in (props.config || {}) ? props.config.type : ''; - if (typeof rawFieldType !== 'string' || !rawFieldType) { + let resolvedType: unknown; + try { + resolvedType = resolveConfig(mForm, rawFieldType, props); + } catch { + return undefined; + } + // 函数型 type 与 Container 的 resolveItemType 对齐;异步 type 无法同步判定,跳过 + if (ignorePromise(resolvedType) || typeof resolvedType !== 'string' || !resolvedType) { return undefined; } // 统一将驼峰形式(如 radioGroup)归一化为连字符形式(radio-group),与内置规则的 key 保持一致 - const fieldType = toLine(rawFieldType); + const fieldType = toLine(resolvedType); const customValidator = getTypeMatchRule(fieldType); diff --git a/packages/form/tests/unit/utils/form.spec.ts b/packages/form/tests/unit/utils/form.spec.ts index dda57781..8b453ae4 100644 --- a/packages/form/tests/unit/utils/form.spec.ts +++ b/packages/form/tests/unit/utils/form.spec.ts @@ -108,6 +108,8 @@ describe('filterFunction', () => { expect(receivedArgs.prop).toBe('testProp'); expect(receivedArgs.index).toBe(5); expect(receivedArgs.config).toEqual({ type: 'text' }); + expect(receivedArgs.formValue).toEqual({ form: 'formValue' }); + expect(receivedArgs.formValues).toBe(receivedArgs.formValue); }); test('config 函数通过 mForm 读穿到宿主 context', () => { diff --git a/packages/form/tests/unit/utils/typeMatch.spec.ts b/packages/form/tests/unit/utils/typeMatch.spec.ts index 23303f7f..22035859 100644 --- a/packages/form/tests/unit/utils/typeMatch.spec.ts +++ b/packages/form/tests/unit/utils/typeMatch.spec.ts @@ -247,14 +247,13 @@ describe('validateTypeMatch', () => { expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组\n\n请参考以下示例值:["a"]'); }); - test('select options 为函数 / group', () => { + test('select options 为函数时求值后做枚举校验', () => { const fnConfig = { type: 'select', options: () => [{ text: 'A', value: 1 }], }; expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined(); - // options 为函数(动态)时,跳过「不在可选项中」枚举校验 - expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBeUndefined(); + expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('2 不在可选项中\n\n请使用以下某一个值:1'); const groupConfig = { type: 'select', @@ -271,6 +270,77 @@ describe('validateTypeMatch', () => { expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中\n\n请使用以下某一个值:"a"'); }); + test('select options 函数返回空 / Promise / 非数组 / 抛错时跳过枚举', () => { + expect(validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => [] }))).toBeUndefined(); + expect( + validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => Promise.resolve([{ value: 1 }]) })), + ).toBeUndefined(); + expect(validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => ({ value: 1 }) }))).toBeUndefined(); + expect(validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => null }))).toBeUndefined(); + expect( + validateTypeMatch( + 2, + mForm, + propsOf({ + type: 'select', + options: () => { + throw new Error('options boom'); + }, + }), + ), + ).toBeUndefined(); + }); + + test('select options 函数可读 model / formValue / formValues', () => { + const form = { ...mForm, values: { mode: 'a' } }; + const byFormValues = { + type: 'select', + options: (_mForm: any, { formValues }: any) => + formValues.mode === 'a' ? [{ text: 'A', value: 1 }] : [{ text: 'B', value: 2 }], + }; + expect(validateTypeMatch(1, form, propsOf(byFormValues))).toBeUndefined(); + expect(validateTypeMatch(2, form, propsOf(byFormValues))).toBe('2 不在可选项中\n\n请使用以下某一个值:1'); + + const byModel = { + type: 'select', + options: (_mForm: any, { model }: any) => [{ text: 'A', value: model.choice }], + }; + expect(validateTypeMatch(1, mForm, propsOf(byModel, { choice: 1 }))).toBeUndefined(); + expect(validateTypeMatch(2, mForm, propsOf(byModel, { choice: 1 }))).toBe( + '2 不在可选项中\n\n请使用以下某一个值:1', + ); + }); + + test('radio-group / checkbox-group / cascader 函数 options 求值后做枚举校验', () => { + const radioOptions = () => [{ text: 'A', value: 1 }]; + expect(validateTypeMatch(1, mForm, propsOf({ type: 'radio-group', options: radioOptions }))).toBeUndefined(); + expect(validateTypeMatch(2, mForm, propsOf({ type: 'radio-group', options: radioOptions }))).toBe( + '2 不在可选项中\n\n请使用以下某一个值:1', + ); + + const checkboxOptions = () => [{ text: 'A', value: 'a' }]; + expect( + validateTypeMatch(['a'], mForm, propsOf({ type: 'checkbox-group', options: checkboxOptions })), + ).toBeUndefined(); + expect(validateTypeMatch(['b'], mForm, propsOf({ type: 'checkbox-group', options: checkboxOptions }))).toBe( + 'b 不在可选项中\n\n请使用以下某一个值:"a"', + ); + + const cascaderOptions = () => [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [{ value: 'hangzhou', label: 'Hangzhou' }], + }, + ]; + expect( + validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options: cascaderOptions })), + ).toBeUndefined(); + expect( + validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options: cascaderOptions })), + ).toBe('zhejiang,ningbo 不在可选项中\n\n请使用以下某一个值:"hangzhou"'); + }); + test('select allowCreate / remote 不做枚举', () => { expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined(); // allowCreate 无 options 时跳过类型校验 @@ -510,9 +580,31 @@ describe('validateTypeMatch', () => { expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined(); }); - test('动态 type 函数解析', () => { + test('动态 type 函数解析后按解析出的 type 校验', () => { expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'text', name: 'field' }))).toBeUndefined(); expect(validateTypeMatch(1, mForm, propsOf({ type: () => 'number', name: 'field' }))).toBeUndefined(); + expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'number', name: 'field' }))).toMatch(/类型应为数字/); + expect( + validateTypeMatch(2, mForm, propsOf({ type: () => 'select', name: 'field', options: [{ text: 'A', value: 1 }] })), + ).toMatch(/不在可选项中/); + expect( + validateTypeMatch( + 2, + mForm, + propsOf({ type: () => 'select', name: 'field', options: () => [{ text: 'A', value: 1 }] }), + ), + ).toMatch(/不在可选项中/); + expect( + validateTypeMatch( + 'ok', + mForm, + propsOf({ + type: () => { + throw new Error('type boom'); + }, + }), + ), + ).toBeUndefined(); }); test('type 为异步函数(返回 Promise)时跳过校验', () => { @@ -524,6 +616,53 @@ describe('validateTypeMatch', () => { expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: async () => 'text', name: 'field' }))).toBeUndefined(); }); + test('异步 type / options / defaultValue / valueSeparator reject 时不产生未捕获 rejection', async () => { + const unhandled: any[] = []; + const onUnhandled = (reason: any) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + + const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [{ value: 'hangzhou', label: 'Hangzhou' }], + }, + ]; + + expect( + validateTypeMatch('ok', mForm, propsOf({ type: () => Promise.reject(new Error('type reject')) })), + ).toBeUndefined(); + expect( + validateTypeMatch( + 2, + mForm, + propsOf({ type: 'select', options: () => Promise.reject(new Error('options reject')) }), + ), + ).toBeUndefined(); + expect( + validateTypeMatch( + '1', + mForm, + propsOf({ type: 'number', defaultValue: () => Promise.reject(new Error('defaultValue reject')) }), + ), + ).toBe('1 类型应为数字\n\n请参考以下示例值:123'); + expect( + validateTypeMatch( + 123, + mForm, + propsOf({ + type: 'cascader', + options, + valueSeparator: () => Promise.reject(new Error('valueSeparator reject')), + }), + ), + ).toBeUndefined(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toEqual([]); + }); + test('cascader valueSeparator 为异步函数(返回 Promise)时跳过校验', () => { const options = [ {