From 0feada8d1fa4057444da746040b3824df09eef07 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Wed, 22 Jul 2026 16:51:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(form):=20=E7=BB=9F=E4=B8=80=20extendState?= =?UTF-8?q?=20=E5=90=88=E5=B9=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/form/src/Form.vue | 20 +++------- packages/form/src/submitForm.ts | 16 +++----- packages/form/src/utils/form.ts | 37 ++++++++++++++++++ packages/form/tests/unit/Form.extra.spec.ts | 42 +++++++++++++++++++++ packages/form/tests/unit/submitForm.spec.ts | 16 ++++++++ 5 files changed, 106 insertions(+), 25 deletions(-) diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index 81de6ace..711fb27d 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -40,7 +40,7 @@ import { setValueByKeyPath } from '@tmagic/utils'; import Container from './containers/Container.vue'; import { getConfig } from './utils/config'; -import { initValue } from './utils/form'; +import { applyExtendState, initValue } from './utils/form'; import type { ChangeRecord, ContainerChangeEventData, @@ -222,11 +222,10 @@ const formState: FormState = reactive({ * 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发 * `extendState` 重跑(旧版的性能问题修复点)。 * - * 实现细节: - * - data 描述符:通过 `formState[key] = value` 走 reactive proxy 的 set, - * 触发依赖通知;与旧版「逐项赋值」语义完全等价。 - * - accessor 描述符(`{ get stage() {...} }`)按原样写入 formState,调用方 - * 可以自行控制读时求值;强制 `configurable: true` 以便下一次重跑可再 define。 + * 实现细节:合并逻辑统一收口在 `applyExtendState`(utils/form)—— + * data 描述符走 reactive proxy 的 set 触发依赖通知(与旧版「逐项赋值」语义等价), + * accessor 描述符按原样 defineProperty 支持读时求值; + * props 派生的只读 getter 字段(keyProp 等)以普通字段形式返回时会被跳过并告警。 */ watchEffect(async (onCleanup) => { const { extendState } = props; @@ -246,14 +245,7 @@ watchEffect(async (onCleanup) => { } if (stale) return; - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) { - if ('value' in descriptor) { - (formState as any)[key] = (state as any)[key]; - } else { - descriptor.configurable = true; - Object.defineProperty(formState, key, descriptor); - } - } + applyExtendState(formState, state); }); provide('mForm', formState); diff --git a/packages/form/src/submitForm.ts b/packages/form/src/submitForm.ts index 690ebfc6..ae16daf3 100644 --- a/packages/form/src/submitForm.ts +++ b/packages/form/src/submitForm.ts @@ -18,6 +18,7 @@ import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue'; +import { applyExtendState } from './utils/form'; import Form from './Form.vue'; import type { ChangeRecord, FormConfig, FormState } from './schema'; @@ -192,17 +193,10 @@ const mountFormInstance = (options: MountFormInstanceOptions): Promise console.error('[MForm] extendState failed:', e); return; } - const apply = (state: Record | null | undefined) => { - if (!state) return; - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) { - if ('value' in descriptor) { - (form.formState as any)[key] = (state as any)[key]; - } else { - descriptor.configurable = true; - Object.defineProperty(form.formState, key, descriptor); - } - } - }; + // 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段 + // (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错 + const apply = (state: Record | null | undefined) => + applyExtendState(form.formState, state); if (result && typeof result.then === 'function') { result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e)); } else { diff --git a/packages/form/src/utils/form.ts b/packages/form/src/utils/form.ts index 17127206..58e6ca42 100644 --- a/packages/form/src/utils/form.ts +++ b/packages/form/src/utils/form.ts @@ -441,6 +441,43 @@ export const sortChange = (data: any[], { prop, order }: SortProp) => { } }; +/** + * 将 extendState 返回的扩展字段合并进 formState。 + * + * - data 描述符(普通字段)通过 `formState[key] = value` 写入,走 reactive proxy 的 set, + * 触发依赖通知; + * - accessor 描述符(`{ get stage() { return ... } }`)按原样 defineProperty,调用方 + * 可控制读时求值;强制 `configurable: true` 以便下一次合并可再 define。 + * + * 注意:formState 上由 props 派生的字段(keyProp / popperClass / config / initValues / + * isCompare / lastValues / parentValues)是只读 getter(无 setter),extendState 若以 + * 普通字段形式返回同名 key,直接赋值会让 proxy 的 set trap 失败并抛出 + * `TypeError: 'set' on proxy: trap returned falsish`,这里统一跳过并告警; + * 如确需覆盖,可在 extendState 中以 get 访问器形式返回。 + */ +export const applyExtendState = (formState: FormState, state: Record | null | undefined): void => { + if (!state) return; + + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) { + if (!('value' in descriptor)) { + descriptor.configurable = true; + Object.defineProperty(formState, key, descriptor); + continue; + } + + const targetDescriptor = Object.getOwnPropertyDescriptor(formState, key); + if (targetDescriptor && !('value' in targetDescriptor) && typeof targetDescriptor.set !== 'function') { + console.warn( + `[MForm] extendState: "${key}" is a read-only field derived from props and cannot be assigned a plain value. ` + + 'Return it as a getter accessor if you really need to override it.', + ); + continue; + } + + (formState as any)[key] = (state as any)[key]; + } +}; + export const createObjectProp = (prop: string, key: string, name?: string | number) => { if (prop === '') { return key; diff --git a/packages/form/tests/unit/Form.extra.spec.ts b/packages/form/tests/unit/Form.extra.spec.ts index b747b8f2..61f178c7 100644 --- a/packages/form/tests/unit/Form.extra.spec.ts +++ b/packages/form/tests/unit/Form.extra.spec.ts @@ -173,6 +173,48 @@ describe('Form.vue —— extendState', () => { expect(v2).toMatch(/^stage-/); }); + test('extendState 以普通字段返回 props 派生的只读字段时跳过并告警,不抛错', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const wrapper = mountForm({ + keyProp: 'id', + extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }), + }); + + await nextTick(); + await nextTick(); + await nextTick(); + + // 只读派生字段保持 props 值,未被覆盖 + expect((wrapper.vm.formState as any).keyProp).toBe('id'); + expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true); + // 其他字段正常合并 + expect((wrapper.vm.formState as any).extra).toBe('ok'); + expect(warnSpy).toHaveBeenCalled(); + expect(wrapper.find('.m-form').exists()).toBe(true); + + warnSpy.mockRestore(); + }); + + test('extendState 以 get 访问器返回只读字段同名 key 时允许显式覆盖', async () => { + const wrapper = mountForm({ + keyProp: 'id', + extendState: () => + Object.defineProperties( + {}, + { + keyProp: { enumerable: true, get: () => 'custom-key' }, + }, + ), + }); + + await nextTick(); + await nextTick(); + await nextTick(); + + expect((wrapper.vm.formState as any).keyProp).toBe('custom-key'); + }); + test('extendState 同步段读到的响应式数据变化时会重跑', async () => { const username = ref('alice'); const calls: string[] = []; diff --git a/packages/form/tests/unit/submitForm.spec.ts b/packages/form/tests/unit/submitForm.spec.ts index f21aece7..a62e8158 100644 --- a/packages/form/tests/unit/submitForm.spec.ts +++ b/packages/form/tests/unit/submitForm.spec.ts @@ -79,6 +79,22 @@ describe('submitForm', () => { expect(extendState).toHaveBeenCalled(); }); + test('extendState 返回 keyProp 等只读派生字段时不抛错且正常 resolve', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const values = await submitForm({ + config: [{ type: 'text', name: 'text', text: 'text' }], + initValues: { text: 'foo' }, + extendState: () => ({ keyProp: 'custom', extra: 'value' }), + appContext, + }); + + expect(values).toEqual({ text: 'foo' }); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + test('在嵌套 items 配置下也能正确 resolve', async () => { const values = await submitForm({ config: [