Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

表单渲染器计算的 emptyHint 被每个选项 widget 丢弃,用户看到的是硬编码英文 #3231

Closed

Description

@os-zhuang

在 objectui#3221(给 FieldWidgetComponentProps 去掉 [key: string]: any)时发现,不在该 PR 修。

现象

packages/components/src/renderers/form/form.tsx 计算依赖门控提示并作为 prop 传给已注册的 field widget:

// form.tsx:1351 —— #2284 的门控提示
emptyHint: gatedHint,

四个选项 widget 全部把它解构掉并丢弃,然后渲染自己硬编码的英文:

  • packages/fields/src/widgets/SelectField.tsx — emptyHint: _emptyHint,
  • packages/fields/src/widgets/MultiSelectField.tsx — emptyHint: _emptyHint,
  • packages/fields/src/widgets/RadioField.tsx — emptyHint: _emptyHint,
  • packages/fields/src/widgets/CheckboxesField.tsx — emptyHint: _emptyHint,

替代文案例如 MultiSelectField:

const hint = gated
  ? `Select ${dependsOnFields.join(' / ')} first`
  : 'No options available';

解构本身是正当的(不解构就会随 ...props 铺到 DOM 上),问题在于解构之后没有使用。

后果

  1. 宿主计算的提示到不了用户。emptyHint 只在内建(未注册)select 分支生效(form.tsx:1824:{emptyHint || 'No options available'})。一旦该字段类型走 field:<type> 注册表(常态),提示就被换掉了。
  2. 绕过了 i18n。widget 内的替代文案是硬编码英文字面量,而 useFieldTranslation() 在同一个文件里已经可用 —— 违反仓库 i18n 惯例。
  3. 这是一个 declared ≠ delivered 的缺口(objectstack AGENTS.md 主令 [WIP] Enhance every detail of the designer #10):prop 被声明、被生产、被传输,然后被无声丢弃。

为什么一直没被发现

FieldWidgetComponentProps 当时以 [key: string]: any 结尾,所以 emptyHint 既不是被声明的契约、也不会因为缺失而报错 —— 没有任何检查能看到"生产了但没人用"。objectui#3221 把该键显式声明了出来,这个缺口才可见。

建议方向(需维护者定夺)

  • A:widget 优先用 emptyHint,自己的文案退化为兜底 —— 单一提示来源,宿主可覆盖。
  • B:widget 保留自有文案但改走 useFieldTranslation(),同时把 emptyHint 从表单渲染器这条路径上撤掉 —— 承认 widget 拥有该文案。

A 更符合契约优先(一个生产者、一个消费者);B 更简单但要正式承认这个 prop 对已注册 widget 无效。任一方向都应有测试钉住,否则会再次静默漂移。

关联:objectui#3221、objectui#3161、#2284。

Activity

  1. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    认领:PM 循环第 2 轮
    会话:session_01NVPjPzmmAJ2Ngtvgg5MSRa(objectui 分片 PM,账号 xuyushun441-sys,已在 objectstack#4604 登记)
    分支:claude/issue-3231-empty-hint-single-source
    Worktree:objectui-issue-3231

    决策裁定:走 A(PM 拍板,不上升维护者,可否决)

    原单把 A/B 标为"需维护者定夺"。按 SKILL.md 的升级门槛,我判定不需要上升 —— 这是「declared ≠ delivered」的恢复不变量类,而且仓规已经把答案写死了。

    A:widget 优先用宿主传来的 emptyHint,自有文案退化为兜底。

    按两条评估轴:

    • 长远合理性:emptyHint 已经被声明、被表单渲染器计算、被传输(form.tsx:1351),然后被四个 widget 解构后丢弃。B(把 emptyHint 从这条路径上撤掉、承认 widget 拥有文案)等于承认宿主无法影响提示,而 form.tsx:1824 的内建分支又确实在用它 —— 那会留下"内建分支宿主可覆盖、注册 widget 不可"的分裂语义,比现状更难解释。A 是一个生产者、一个消费者。
    • 防 AI 写错:B 会让"宿主计算了一个提示"这件事变成没有出口的死代码,而死代码正是本轮 TextAreaField 读取的 mobileFullscreen prop 全仓无人生产,注释却说"宿主表单传入" #3232 刚清掉的那种东西 —— 我不打算在同一天用另一单再造一份。A 之后,宿主传了就一定生效,没传才用兜底,行为可预测。

    两件必须一起做的事,否则本单白修

    1. i18n 不是附带项。 原单点明四个 widget 的兜底文案是硬编码英文字面量('No options available'、`Select ${dependsOnFields.join(' / ')} first`),而 useFieldTranslation() 在同一个文件里已经可用。无论 A 还是 B,这条都得修 —— 兜底文案必须走翻译。只把 emptyHint 接上、兜底仍是硬编码英文,等于只修了一半。

    2. 必须有测试钉住。 原单说"任一方向都应有测试钉住,否则会再次静默漂移" —— 这条是硬要求。至少要覆盖:宿主传了 emptyHint 时四个 widget 都显示它(不是自己的文案),没传时显示已翻译的兜底。四个 widget 都要覆盖,不能只测一个 —— 它们是四份独立的复制品,这正是它们能一起漂移的原因。

    复核记录

    origin/main 上确认:四个 widget(SelectField / MultiSelectField / RadioField / CheckboxesField)均为 emptyHint: _emptyHint, 形态解构后丢弃。解构本身是正当的(不解构会随 ...props 铺到 DOM 上),问题只在解构之后没有使用 —— 修法是用它,不是别解构它。

    ⚠️ scope fence:本单在 packages/fields。packages/components/src/renderers/form/form.tsx 是 emptyHint 的生产者,读可以,改要有强理由并在报告里说明 —— 内建 select 分支(form.tsx:1824)是另一条独立活路径,不要顺手统一。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    PM 验收:实现通过,但我没有合并 —— 需要维护者拍一个板

    PR #3262 已从 draft 转为 ready for review,刻意没有入合并队列。这是本轮五单里唯一一个我不自行合并的。

    代码质量核对(GitHub 实况):CI 14 绿 + 2 path-filter skipped,零失败;三处独立 mutation 验证(widget 忽略 emptyHint → 20 红;stripRegisteredFieldProps 停止转发 → 8 红;isOptionField 退回原比较 → 5 红且恰好红在四个 field: 前缀用例上)。四个 widget 收敛到共享的 OptionsEmptyState,十个语言包都补了 fields.options.*,i18n 缺口修掉了。这些都对。

    为什么不合并:它顺带打开了一个此前从未生效的行为开关

    开发 agent 诚实地把这条列进了"需要审查者看见"而不是埋掉 —— 我核对了源码,属实,而且比报告里说的更值得重视。

    原单只说"四个 widget 把 emptyHint 丢掉了"。实测这条链断了三处,而最靠前的一处不是丢弃,是根本没算:

    // 修复前
    const isOptionField = resolvedType === 'select' || 'radio' || 'multiselect' || 'checkboxes';

    对象派生表单经 mapFieldTypeToFormType 发出的是 field:select 这类带前缀的 id,一个都匹配不上 —— 也就是说在 console 的常规路径上,整个 #2284 级联块从来没有执行过。修法(用 normalizeFieldType 归一化)是对的,但它的后果不止于提示:

    // form.tsx:1358
    options: isOptionField ? effectiveOptions : fieldProps.options,
    // form.tsx:1366
    disabled: disabled || fieldDisabled || isSubmitting || optionGroupGated,

    于是 console 里被 dependsOn 门控的选项字段,从今往后会:

    1. 拿到经 visibleWhen 预过滤的 options;
    2. 在父字段未填时渲染成 disabled —— 此前它们是可用的。

    需要拍板的问题(一句话)

    「被 dependsOn 门控的选项字段应当 disabled」这条 #2284 的既定设计,现在要不要真正在 console 的对象派生表单上生效?

    两条轴上的分析

    长远合理性 —— 倾向「生效」。 那行原始比较是 bug 而不是刻意限制:同一段注释白纸黑字写着"gate the whole control while a declared dependsOn parent is still empty",意图从来就是通用的,只是字符串比较漏了带前缀的那一半。让它继续瘫痪,等于在 console 上长期维持一个"声明了却不兑现"的能力 —— 正是本仓反复在清的那种东西(#3232、#3236 都是同族)。而且 widget 侧本来就有自己的 useCascadingOptions,门控语义在 widget 内一直是生效的;渲染器这一侧不生效,反而是两侧不一致。

    防 AI 写错 —— 同样倾向「生效」。 收敛后 field:select 与 select 在渲染器与 stripRegisteredFieldProps 两处用同一个归一化,不再各判各的;这正是主令 #12 说的在生产者侧收敛。放着不修,下一个人还要再发现一次"为什么我的门控提示不出现"。

    但我不自行合并的理由: 这条改的是 console 主表单路径上的控件可用性,是用户直接看得见的交互变化,而原单从头到尾只在讨论一句提示文案。按 SKILL.md 的升级门槛,"选项在产品语义上真正分岔"就该由维护者定 —— 「门控时要不要禁用控件」是一个 UX 决定,不是实现细节,而且没有任何 issue 讨论过它。测试全绿这一点在这里的证据力也弱于平常:受影响的是对象派生的 console 表单,单元测试未必真的走到那条端到端路径。

    三个可选动作

    维护者定一个,我照办。在此之前 PR 停在 ready-for-review,不入队。

    另外两件

    • 越界发现 表单渲染器内建 select 分支的空状态文案仍是硬编码英文(form.tsx:1843) #3263 已入队:内建(未注册)select 分支仍渲染硬编码英文 'No options available' —— 同族缺口,但被我给的 scope fence 明确挡在外面。现在 fields.options.empty 这个 key 在十个语言包里都有了,修它是一行加一条 defaults 映射。
    • 那条 stripRegisteredFieldProps 的转发做成了允许清单(只发给四个级联选项类型)而不是取消剥离,理由正确:其余已注册 widget 会把剩余 props 铺到 DOM 上,一个未知的 emptyHint 属性就是一条 React 警告。反向断言也钉住了这一点。

    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    维护者裁定:A —— 按现状合并

    已收到答复,PR #3262 已入合并队列,needs-user-decision 标签撤下。

    裁定内容:#2284「被 dependsOn 门控的选项字段渲染为 disabled」这条既定设计,正式在 console 的对象派生表单上生效。此前 isOptionField 的裸字符串比较让整个级联块在 field: 前缀路径上从未执行,那是 bug,不是刻意限制。

    随之确立的行为(记在这里,便于后来者查)

    自本 PR 起,console 中带 dependsOn 的选项字段在父字段未填时:

    1. 控件渲染为 disabled(form.tsx 的 disabled: … || optionGroupGated);
    2. options 是经 visibleWhen 预过滤的集合;
    3. 空态显示宿主计算的提示(用控制字段的标签,不是原始元数据名),语言跟随会话。

    若日后有人觉得"门控时禁用"观感不对,那是对 #2284 设计的修改,应当单独立单说明理由 —— 不要通过把 optionGroupGated 从 disabled 计算里悄悄摘掉来实现,那会让本单再次回到"声明了却不兑现"的状态。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions