Browse Source

fix(@vben-core/form-ui): 字段名与 <form> 固有属性冲突时剥离原生 name(#8214) (#8321)

* 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
俞可 · YU KE 1 month ago
committed by GitHub
parent
commit
c26a0897f9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/eight-deserts-kneel.md
  2. 228
      packages/@core/ui-kit/form-ui/__tests__/form-field-node-name.test.ts
  3. 61
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

5
.changeset/eight-deserts-kneel.md

@ -0,0 +1,5 @@
---
'@vben-core/form-ui': patch
---
fix(@vben-core/form-ui): 字段名与 <form> 固有属性冲突时剥离原生 name(#8214)

228
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":<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');
});
});

61
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

@ -289,6 +289,46 @@ const fieldProps = computed(() => {
};
});
// 字段名与 <form> 固有属性冲突时(如 nodeName),不把 name 落到原生控件上:
// <input name="nodeName"> 会劫持 form.nodeName 访问器,返回控件元素而非字符串,
// 导致 popper/floating 计算(getNodeName → nodeName.toLowerCase())崩溃(issue #8214)。
// 判定:form 固有属性均不为 undefined(''、0、null、对象、函数),
// 而 form 上不存在的命名属性访问返回 undefined——以此区分冲突与否。
const fieldNameConflictCache = new Map<string, boolean>();
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');
}
// 合并完成后统一剥离与 <form> 固有属性冲突的 name(含用户 binds 显式传入的值),
// 防止 <input name="nodeName"> 劫持 form.nodeName 访问器(issue #8214);
// 不冲突的 name(无论生成还是绑定来源)原样保留。
// 边界:fieldBindEvent 产出过 name 键时,binds.name 是模型数据绑定
// (modelPropName / modelPropNameMap 显式解析为 'name'),承载的是表单数据
// 而非原生属性,即便其值与 <form> 固有属性同名也不得剥离。
// 边界:组件把 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;
}

Loading…
Cancel
Save