返回博客

Base UI rc.0 的 Select 没有 placeholder prop——用 null 项注入绕坑

@base-ui-components/react 做一个下拉选择器,照着 context7 / 官方文档写 <Select.Value placeholder="请选择…" />,TypeScript 直接报红:Property 'placeholder' does not exist on type SelectValueProps。用 as any 绕过之后,trigger 仍然空白——placeholder 文本根本不显示。单测倒是绿的,但那是假绿。

现象

  • tscSelectValueProps 上没有 placeholder 属性,只有 children
  • 强行用类型断言传入后,渲染结果是空白 trigger,用户看不到占位文本。
  • 单测里断言 span.getAttribute("placeholder") 通过——attribute 确实被挂上去了,但 可见文本依然为空(假绿)。

根因

@base-ui-components/react 1.0.0-rc.0Select.Value 只有 children propplaceholderv1.2+ 才新增的。

context7 和官方文档返回的示例基于最新稳定版,而项目 lockfile 锁的是 rc.0——这是两套完全不同的 API,文档里的 placeholder 在 rc.0 根本不存在。

坑:文档版本 ≠ lockfile 版本,rc.0 的 Value 无 placeholder

rc.0 的 SelectValueProps

{
  children?: React.ReactNode | ((value: any) => React.ReactNode);
}

传入 placeholder 只是给 DOM 挂了一个无意义 attribute,不会被渲染成文本。单测只验 attribute 就会假绿,必须验 textContent

排查:probe 真实版本的 .d.ts

别靠文档或记忆,直接读安装版本的类型声明:

SEL=$(node -e "console.log(require.resolve('@base-ui-components/react/select',{paths:['packages/ui']}))")
cat "$(dirname "$SEL")/value/SelectValue.d.ts"

rc.0 只有 children,无 placeholder。再读 SelectValue.js 的渲染逻辑,children 有三个分支:

  1. 函数 childrenProp(value) → 自己控制渲染(注意收到的是 raw value,不是 label)。
  2. 非空非函数 children → 恒显示该节点,选了值也不会换——所以不能直接写字符串当 placeholder。
  3. 不写 children → 自动调用 resolveSelectedLabel(value, items):有值时映射 items 里对应 label,无值时命中 items.find(it => it.value == null) 并显示其 label

这就是正解的切入点。

正解

items 前插入一个 value: null 的哑项作为 placeholder,Select.Value 不写 children,让它自动走第三个分支:

// 包装一层,接受 placeholder prop,内部转成 null 项注入
export function Select({ items, placeholder, children, ...props }) {
  const finalItems =
    placeholder != null && items != null
      ? [{ value: null, label: placeholder }, ...items]
      : items;
 
  return (
    <BaseSelect.Root items={finalItems} {...props}>
      {children}
    </BaseSelect.Root>
  );
}
 
// Trigger 内的 Value 不写 children
// 无值时自动显示 null 项的 label(即 placeholder)
// 有值时显示对应 label
<BaseSelect.Value className="truncate data-[placeholder]:text-muted" />

列表里只渲染真实选项(map 原始 items,不含 null 项),null 项因为不在列表里也无法被用户选中,完全符合 placeholder 语义。

正解:items 注入 null 项,Value 不写 children

| 写法 | 无值时 | 有值时 | |------|--------|--------| | <Value/> + items 无 null 项 | 空白 | 显示 label ✅ | | <Value placeholder="X"/> (rc.0) | 空白(prop 无效) | — | | <Value/> + items 含 {value:null,label:"X"} | 显示 "X" ✅ | 显示 label ✅ |

同时修正测试写法,改验 textContent 而非 attribute:

// ✅ 验可见文本
expect(screen.getByRole("combobox").textContent).toContain("请选择");
 
// ❌ 别只验 attribute(attribute 在但文本不渲染 = 假绿)
// expect(span.getAttribute("placeholder")).toBe("请选择");

一句话外卖

context7 / 官方文档给的是最新版 API,你的 lockfile 锁的是 rc.0——凡是 rc/beta 依赖,API 用法必须 probe 安装版本的真实 .d.ts + 跑真实渲染验证,不能信文档、不能信 AI 记忆、不能只验 attribute。