mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-22 22:18:00 +00:00
fix(form): 统一 extendState 合并逻辑
This commit is contained in:
parent
d27710c464
commit
0feada8d1f
@ -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<FormState>({
|
||||
* 这里不再重复同步;因此 `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);
|
||||
|
||||
@ -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 = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
||||
console.error('[MForm] extendState failed:', e);
|
||||
return;
|
||||
}
|
||||
const apply = (state: Record<string, any> | 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<string, any> | null | undefined) =>
|
||||
applyExtendState(form.formState, state);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
||||
} else {
|
||||
|
||||
@ -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<string, any> | 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;
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user