From c26a0897f98ef1ed0e5849956ea0e162664bfede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9E=E5=8F=AF=20=C2=B7=20YU=20KE?= <1798456934@qq.com> Date: Sat, 29 Aug 2026 15:40:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(@vben-core/form-ui):=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=20
=20=E5=9B=BA=E6=9C=89=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E5=86=B2=E7=AA=81=E6=97=B6=E5=89=A5=E7=A6=BB=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20name=EF=BC=88#8214=EF=BC=89=20(#8321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(@vben-core/form-ui): strip native name attr when fieldName collides with form properties An 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 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 * fix(@vben-core/form-ui): 字段名与 固有属性冲突时剥离原生 name (#8214) Co-Authored-By: EvoX * fix(@vben-core/form-ui): keep name-resolved model bindings intact in conflict sweep (#8214) Co-Authored-By: EvoX * fix(@vben-core/form-ui): preserve declared name props in conflict sweep (#8214) Co-Authored-By: EvoX * fix(@vben-core/form-ui): resolve string components before name-prop introspection (#8214) Co-Authored-By: EvoX --------- Co-authored-by: EvoX --- .changeset/eight-deserts-kneel.md | 5 + .../__tests__/form-field-node-name.test.ts | 228 ++++++++++++++++++ .../form-ui/src/form-render/form-field.vue | 61 ++++- 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 .changeset/eight-deserts-kneel.md create mode 100644 packages/@core/ui-kit/form-ui/__tests__/form-field-node-name.test.ts diff --git a/.changeset/eight-deserts-kneel.md b/.changeset/eight-deserts-kneel.md new file mode 100644 index 000000000..1e868b988 --- /dev/null +++ b/.changeset/eight-deserts-kneel.md @@ -0,0 +1,5 @@ +--- +'@vben-core/form-ui': patch +--- + +fix(@vben-core/form-ui): 字段名与 固有属性冲突时剥离原生 name(#8214) diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-field-node-name.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-field-node-name.test.ts new file mode 100644 index 000000000..cf7e57660 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-field-node-name.test.ts @@ -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": 会劫持 + // 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 的 必须支持 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,否则 + // 会劫持 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 语义),即便其值与 固有属性同名也不得剥离; + // 原生属性路径仍由 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'); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue index 28856cae2..4358fb9a2 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue @@ -289,6 +289,46 @@ const fieldProps = computed(() => { }; }); +// 字段名与 固有属性冲突时(如 nodeName),不把 name 落到原生控件上: +// 会劫持 form.nodeName 访问器,返回控件元素而非字符串, +// 导致 popper/floating 计算(getNodeName → nodeName.toLowerCase())崩溃(issue #8214)。 +// 判定:form 固有属性均不为 undefined(''、0、null、对象、函数), +// 而 form 上不存在的命名属性访问返回 undefined——以此区分冲突与否。 +const fieldNameConflictCache = new Map(); +function conflictsWithFormProperty(fieldName: string): boolean { + let cached = fieldNameConflictCache.get(fieldName); + if (cached === undefined) { + cached = false; + if (typeof document !== 'undefined') { + try { + cached = + Reflect.get(document.createElement('form'), fieldName) !== undefined; + } catch { + cached = false; + } + } + fieldNameConflictCache.set(fieldName, cached); + } + return cached; +} + +// 组件定义内省:props(数组或对象)显式声明 name 时,binds.name 是组件的 +// 语义 prop 而非原生 fallthrough 属性,不参与原生剥离判定(coderabbit review +// 边界修正)。入参必须传解析后的 FieldComponent:字符串组件名经 componentMap +// 解析后再内省;未注册的字符串组件(解析结果为 undefined)按未声明处理, +// 维持原生剥离的默认安全性。 +function declaresNameProp(comp: unknown): boolean { + const compProps = (comp as undefined | { props?: undefined | unknown }) + ?.props; + if (Array.isArray(compProps)) { + return compProps.includes('name'); + } + if (compProps && typeof compProps === 'object') { + return Reflect.has(compProps, 'name'); + } + return false; +} + function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) { const { field } = slotProps; function handleChange(value: any) { @@ -298,7 +338,7 @@ function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) { return { ...slotProps, componentField: { - name: fieldName, + ...(conflictsWithFormProperty(fieldName) ? {} : { name: fieldName }), modelValue: fieldValue.value, onBlur: field.handleBlur, onChange: handleChange, @@ -378,6 +418,25 @@ function createComponentProps(slotProps: RuntimeFieldSlotProps) { Reflect.deleteProperty(binds, 'onUpdate:modelValue'); } + // 合并完成后统一剥离与 固有属性冲突的 name(含用户 binds 显式传入的值), + // 防止 劫持 form.nodeName 访问器(issue #8214); + // 不冲突的 name(无论生成还是绑定来源)原样保留。 + // 边界:fieldBindEvent 产出过 name 键时,binds.name 是模型数据绑定 + // (modelPropName / modelPropNameMap 显式解析为 'name'),承载的是表单数据 + // 而非原生属性,即便其值与 固有属性同名也不得剥离。 + // 边界:组件把 name 声明为语义 prop 时(含字符串组件名经 componentMap + // 解析出的组件),binds.name 是组件 prop 而非原生 fallthrough 属性, + // 同样不剥离;未注册的字符串组件按未声明处理。 + const nameIsModelBinding = Reflect.has(bindEvents, 'name'); + if ( + !nameIsModelBinding && + !declaresNameProp(FieldComponent.value) && + Reflect.has(binds, 'name') && + conflictsWithFormProperty(Reflect.get(binds, 'name') as string) + ) { + Reflect.deleteProperty(binds, 'name'); + } + return binds; }