Browse Source
* fix(@vben-core/form-ui): strip native name attr when fieldName collides with form properties An <input name="nodeName"> inside a vben form hijacks the form.nodeName accessor: it returns the control element instead of the string "FORM". When a TreeSelect dropdown opens, popper/floating computation walks the ancestor chain and calls getNodeName(form) -> form.nodeName.toLowerCase(), which throws. Strip the native name attribute only for field names that collide with <form> built-in properties (nodeName, action, id, name, title, ...); the outer tanstack Field name binding used for field location is unaffected. closes #8214 Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/form-ui): 字段名与 <form> 固有属性冲突时剥离原生 name (#8214) Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/form-ui): keep name-resolved model bindings intact in conflict sweep (#8214) Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/form-ui): preserve declared name props in conflict sweep (#8214) Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/form-ui): resolve string components before name-prop introspection (#8214) Co-Authored-By: EvoX <evox@evomap.ai> --------- Co-authored-by: EvoX <evox@evomap.ai>changeset-release/main
committed by
GitHub
3 changed files with 293 additions and 1 deletions
@ -0,0 +1,5 @@ |
|||||
|
--- |
||||
|
'@vben-core/form-ui': patch |
||||
|
--- |
||||
|
|
||||
|
fix(@vben-core/form-ui): 字段名与 <form> 固有属性冲突时剥离原生 name(#8214) |
||||
@ -0,0 +1,228 @@ |
|||||
|
import type { VueWrapper } from '@vue/test-utils'; |
||||
|
|
||||
|
import { flushPromises, mount } from '@vue/test-utils'; |
||||
|
import { defineComponent, h, nextTick } from 'vue'; |
||||
|
|
||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
// form-ui 是独立包,element-plus 是应用层依赖;测试直接解析 .pnpm 虚拟存储中的真实实现
|
||||
|
// @ts-expect-error - 深路径 import 绕过包解析,类型声明缺失可预期
|
||||
|
import { |
||||
|
ElInput, |
||||
|
ElTreeSelect, |
||||
|
} from '../../../../../node_modules/.pnpm/element-plus@2.14.5_vue@3.5.41_typescript@6.0.3_/node_modules/element-plus/es/index.mjs'; |
||||
|
import { COMPONENT_MAP, setupVbenForm } from '../src/config'; |
||||
|
import { useVbenForm } from '../src/use-vben-form'; |
||||
|
|
||||
|
const wrappers: VueWrapper[] = []; |
||||
|
|
||||
|
// 探针:记录 ElTreeSelect 实际收到的 props/attrs 键
|
||||
|
let capturedTreeSelectKeys: string[] = []; |
||||
|
let capturedTreeSelectNodeName: unknown; |
||||
|
let capturedTreeSelectNameValue: unknown; |
||||
|
let capturedDeclaredName: unknown; |
||||
|
|
||||
|
// 声明 name 为语义 prop 的探针组件:验证 componentProps.name 不被剥离
|
||||
|
const DeclaredNameComponent = defineComponent({ |
||||
|
name: 'DeclaredNameComponent', |
||||
|
props: { |
||||
|
name: { type: String, default: '' }, |
||||
|
}, |
||||
|
setup(props) { |
||||
|
capturedDeclaredName = props.name; |
||||
|
return () => h('div', `name:${props.name ?? ''}`); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const ProbeTreeSelect = defineComponent({ |
||||
|
name: 'ProbeTreeSelect', |
||||
|
inheritAttrs: false, |
||||
|
setup(props, { attrs }) { |
||||
|
capturedTreeSelectKeys = [...Object.keys(props), ...Object.keys(attrs)]; |
||||
|
capturedTreeSelectNodeName = Reflect.get(attrs, 'nodeName'); |
||||
|
capturedTreeSelectNameValue = Reflect.get(attrs, 'name'); |
||||
|
return () => h(ElTreeSelect, { ...props, ...attrs }); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
setupVbenForm({ |
||||
|
config: {}, |
||||
|
rules: {}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
for (const wrapper of wrappers.splice(0)) { |
||||
|
wrapper.unmount(); |
||||
|
} |
||||
|
capturedTreeSelectKeys = []; |
||||
|
capturedTreeSelectNodeName = undefined; |
||||
|
capturedTreeSelectNameValue = undefined; |
||||
|
capturedDeclaredName = undefined; |
||||
|
vi.useRealTimers(); |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
describe('issue #8214: form field named nodeName crashes TreeSelect render', () => { |
||||
|
it('renders MRE schema (TreeSelect parentId + Input nodeName) without error', async () => { |
||||
|
// MRE: TreeSelect(fieldName parentId) + Input(fieldName nodeName)
|
||||
|
const consoleError = vi |
||||
|
.spyOn(console, 'error') |
||||
|
.mockImplementation(() => {}); |
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: ProbeTreeSelect, |
||||
|
componentProps: { data: [] }, |
||||
|
fieldName: 'parentId', |
||||
|
}, |
||||
|
{ |
||||
|
component: ElInput, |
||||
|
fieldName: 'nodeName', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
|
||||
|
let renderError: unknown; |
||||
|
const wrapper = mount(Form, { |
||||
|
global: { |
||||
|
config: { |
||||
|
errorHandler: (error) => { |
||||
|
renderError = error; |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(renderError).toBeUndefined(); |
||||
|
expect(consoleError).not.toHaveBeenCalled(); |
||||
|
|
||||
|
// 打开 TreeSelect 弹层:issue #8214 的崩溃发生在弹层 floating 定位
|
||||
|
// (getNodeName 读取被劫持的 form.nodeName)阶段,此处验证完整打开流程无错
|
||||
|
await wrapper.find('.el-select__wrapper').trigger('click'); |
||||
|
await flushPromises(); |
||||
|
await nextTick(); |
||||
|
expect(renderError).toBeUndefined(); |
||||
|
expect(consoleError).not.toHaveBeenCalled(); |
||||
|
|
||||
|
// 原生控件上不应存在 name="nodeName":<input name="nodeName"> 会劫持
|
||||
|
// form.nodeName 访问器(issue #8214 的根因)
|
||||
|
expect(wrapper.find('input[name="nodeName"]').exists()).toBe(false); |
||||
|
|
||||
|
// 探针结果:ElTreeSelect 收到的键里不应有值为对象的 nodeName 键
|
||||
|
expect(capturedTreeSelectKeys).toContain('name'); |
||||
|
expect(capturedTreeSelectNodeName).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('does not leak a nodeName keyed object into TreeSelect props when field is named nodeName', async () => { |
||||
|
// 反向场景:TreeSelect 字段本身就是 nodeName(最严格情形)
|
||||
|
// 环境前提:happy-dom 的 <form> 必须支持 nodeName 固有属性,
|
||||
|
// 否则 conflictsWithFormProperty 检测会误判(返回 undefined → 不剥离 name)
|
||||
|
expect(Reflect.get(document.createElement('form'), 'nodeName')).toBe( |
||||
|
'FORM', |
||||
|
); |
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: ProbeTreeSelect, |
||||
|
componentProps: { data: [] }, |
||||
|
fieldName: 'nodeName', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
|
||||
|
let renderError: unknown; |
||||
|
const wrapper = mount(Form, { |
||||
|
global: { |
||||
|
config: { |
||||
|
errorHandler: (error) => { |
||||
|
renderError = error; |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(renderError).toBeUndefined(); |
||||
|
// 冲突字段(nodeName)的控件不应收到 name,否则 <input name="nodeName">
|
||||
|
// 会劫持 form.nodeName 访问器(issue #8214)
|
||||
|
expect(capturedTreeSelectKeys).not.toContain('name'); |
||||
|
expect(capturedTreeSelectNodeName).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('keeps model binding when modelPropName resolves to name and its value collides', async () => { |
||||
|
// coderabbit review 边界:modelPropName: 'name' 时 binds.name 承载模型数据
|
||||
|
// (v-model 语义),即便其值与 <form> 固有属性同名也不得剥离;
|
||||
|
// 原生属性路径仍由 conflictsWithFormProperty 阻断。
|
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: ProbeTreeSelect, |
||||
|
componentProps: { data: [] }, |
||||
|
defaultValue: 'nodeName', |
||||
|
fieldName: 'nodeName', |
||||
|
modelPropName: 'name', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
|
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
await nextTick(); |
||||
|
|
||||
|
// 模型绑定存活:name 键存在且承载表单数据(未被子组件 attr 路径劫持)
|
||||
|
expect(capturedTreeSelectKeys).toContain('name'); |
||||
|
expect(capturedTreeSelectNameValue).toBe('nodeName'); |
||||
|
}); |
||||
|
|
||||
|
it('preserves componentProps.name when the component declares name as a semantic prop', async () => { |
||||
|
// coderabbit review 边界:声明了 name 语义 prop 的组件,componentProps.name
|
||||
|
// 是组件 prop 而非原生 fallthrough 属性,值冲突也不得剥离
|
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: DeclaredNameComponent, |
||||
|
componentProps: { name: 'nodeName' }, |
||||
|
fieldName: 'nodeName', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
|
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
await nextTick(); |
||||
|
|
||||
|
// 语义 prop 存活:组件收到的 name 就是用户显式配置的值
|
||||
|
expect(capturedDeclaredName).toBe('nodeName'); |
||||
|
}); |
||||
|
|
||||
|
it('resolves string components through componentMap before the declared-prop check', async () => { |
||||
|
// coderabbit review 边界:字符串组件名经 componentMap 解析出的组件若声明了
|
||||
|
// name 语义 prop,同样不得剥离(守卫须内省解析后的 FieldComponent)
|
||||
|
COMPONENT_MAP.NameDeclared = DeclaredNameComponent; |
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: 'NameDeclared', |
||||
|
componentProps: { name: 'nodeName' }, |
||||
|
fieldName: 'nodeName', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
|
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(capturedDeclaredName).toBe('nodeName'); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue