返回博客

Base UI Field.Error 外部受控时红框有、报错文字消失的根因

@base-ui-components/react 封装设计系统的 Field 组件,逻辑很简单:父级传 error 字符串进来,就把 Field.Root 标记为 invalid,再把字符串塞进 <Field.Error> 显示。

样式层完全正常——输入框红框红环如期出现。但错误文字呢?DOM 里根本找不到那个节点。控制台没有警告,组件没有报错,一片安静。最迷惑的症状:视觉上"看起来有反馈"(有红色),实际上无障碍和视觉文案都是哑的。

现象

// 封装层
<Field.Root invalid={Boolean(error)}>
  <Field.Control render={<input />} />
  <Field.Error>{error}</Field.Error>   {/* 从不渲染 */}
</Field.Root>
  • 输入框:data-invalid 属性挂上了,红框出现了。
  • <Field.Error> 对应的 DOM 节点:消失,aria-describedby 无内容。
  • 去掉 error prop,红框消失——说明 invalid 传递路径是通的,唯独 Field.Error 不响应。

根因

读 Base UI 源码 node_modules/@base-ui-components/react/esm/field/error/FieldError.js,渲染决策只有这几行:

let rendered = false;
if (formError || match === true) { rendered = true; }          // 强制渲染
else if (match) { rendered = Boolean(validityData.state[match]); }
else { rendered = validityData.state.valid === false; }         // 默认分支
if (!rendered) return null;

默认分支检查的是 validityData.state.valid。这个值只有在 Base UI 自己跑完 validate / validationMode 后才会从 null 变成 false

我们完全绕过了 Base UI 的验证机制——valid 永远是 nullnull === falsefalseFieldError 每次都 return null

红框和报错文字走的是两条互不关联的路径

  • 红框:Field.Root invalid={true} → 根 context state.valid = false → 控件写入 data-invalid
  • 报错文字:validityData.state.valid(Base UI 内部验证结果)→ 从未被触发 → 永远 null
坑:不传 match 时,Field.Error 只认 validityData,不认 invalid prop

invalid={true} 让边框变红,但 Field.Error 的渲染条件是独立的 validityData。两者解耦,各走各路。外部控制错误字符串时,validityData 永远停在默认值 valid: null,报错文字因此静默消失。

正解

match={true} 绕过 validityData 检查,强制渲染;用条件渲染在 error 为空时不挂节点:

{error && (
  <Field.Error match={true} className="text-sm text-danger">
    {error}
  </Field.Error>
)}

match === true 命中第一个分支,rendered = true,无论 validityData 状态如何都会渲染。

更完整的封装:

interface FieldProps {
  label?: string;
  error?: string;
  invalid?: boolean;
  children: React.ReactNode;
}
 
export function Field({ label, error, invalid, children }: FieldProps) {
  const isInvalid = invalid ?? Boolean(error);   // error 非空时自动 invalid
 
  return (
    <BaseField.Root invalid={isInvalid}>
      {label && <BaseField.Label>{label}</BaseField.Label>}
      {children}
      {error && (
        <BaseField.Error match={true} className="text-sm text-danger">
          {error}
        </BaseField.Error>
      )}
    </BaseField.Root>
  );
}
正解:match={true} 强制渲染,且 aria-describedby 自动挂

match={true} 不只是绕过验证检查——FieldError 内部仍然调用 setMessageIds,会把错误节点的 id 自动写入控件的 aria-describedby。比自己手写一个 <p> 少很多胶水代码,无障碍免费获得。

排查提示

如果在别的外部受控场景遇到「样式有、内容无」的 Base UI 组件,先去源码找 rendered 或类似的条件决策,再对照:组件依赖的 context 值是否需要 Base UI 内置验证才能写入?如果是,就找对应的 match 参数或强制渲染 flag。

一句话外卖

Field.Root invalid 只管样式,Field.Error 靠的是 validityData——外部控制错误时两者脱节,加 match={true} 强制渲染才能让文字和红框同时出现。