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.

字段 widget 把渲染器专用 props 原样展开到 DOM——schema="[object Object]" 出现在真实输入框上 #3291

Closed

Description

@xuyushun441-sys

在 objectui#3222 / PR #3289 的实测中发现,未在该 PR 修复(影响面覆盖 ~80 个 widget,属独立重构)。

事实

表单渲染器渲染已注册 widget 时,stripRegisteredFieldProps 剥掉了一批渲染器专用键,但 schema 被剥掉后又显式传了回去(< RegisteredComponent schema={fieldSchema} {...registeredProps} / >,因为约 25 个 widget 读 field || schema 取配置)。

而绝大多数 widget 的写法是:

export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps< string >) {
  const { inputType, ...domProps } = props as any;
  return < Input {...domProps} … / >;   // schema 在 domProps 里
}

schema 没被解构出来,于是原样落到 DOM 上。实测(jsdom,真表单 + 真 widget)的输出:

< input class="…" schema="[object Object]" id="_r_0_-form-item"
       aria-describedby="…" aria-invalid="true" type="text" value="" name="t" >

schema="[object Object]" 出现在每一个这样渲染的输入框上。React 不报警(全小写属性会被透传),所以它安静地存在了很久。

为什么值得修

可能的方向(需要拍板,不要直接开工)

  1. 共享的 toDomProps(props) 助手,在 @object-ui/fields 导出,每个 widget 用它替代裸 ...props 展开。显式、可 grep、可测试;但要改 ~80 个文件。
  2. 收敛 schema 与 field 本身(注释里说的那件事),让 schema 不再需要被传下去。更根治,但影响读 field || schema 的 ~25 个 widget。
  3. 什么都不做,只把它记录成已知取舍。

倾向 2 → 1 的顺序,但这是架构判断,应当先定方向。

边界

packages/fields(+ 可能 packages/components/src/renderers/form/)。不动 packages/spec。

关联:objectui#3221(封闭该类型的那一单)、objectui#3222、PR #3289。

Activity

  1. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    ContributorAuthor

    维护者裁决(2026-08-03)

    批准路线 1 + 契约测试,并适用维护者例外条款后移出 v17 发版窗口:

    • 本单不是协议变更——不改任何 metadata 或 widget 契约,只把渲染器专用键从 DOM 上清掉;客户一次性升级的目标不受它影响。而它是本批机械体量最大的(~80 个文件),塞进发版窗口只添风险不添收益。
    • 路线 2(field/schema 收敛)已在 field 与 schema 是同一个意思的两个载体,~25 个 widget 读 field || schema #3233 裁决为 v17 直接落地,所以本单动手时 schema 泄漏源已消失;toDomProps 收口的是 error 及未来一切声明 prop——它不是过渡品,是永久收口点。
    • 同 PR 必须带契约测试:渲染每个注册 widget,断言无未知小写属性落到 DOM 元素——把规则从约定变成门禁,防下一个(AI 写的)widget 裸展开 ...props 静默回归。
    • 路线 3(不做)否决。

    排期:v17 发布后,按包分批派发。摘 needs-user-decision,加 pm:queue。


    Generated by Claude Code


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    ContributorAuthor

    排期提前(维护者 2026-08-03 追加裁决)+ 认领:先审计

    会话 session_01NVPjPzmmAJ2Ngtvgg5MSRa。

    排期从「v17 发布后」提前至立即。 原挂起理由不是协议约束——本单不改任何契约——而是窗口批冲刺期的合并带宽保护。v17 窗口批已 4/4 完成,该理由消失。维护者确认提前。

    但先派只读审计,不直接开工

    原单的方向 2(field/schema 收敛)已由 #3233 / PR #3296 在 v17 内落地,所以原单描述的病征至少有一半的前提已经过时:

    在过时的病征描述上直接派 80 文件的机械改,是让 agent 对着一个不存在的症状写代码。先测量,再定界。

    审计交付:jsdom 实测当前 origin/main 上每个注册 widget 的真实 DOM 输出,列出仍在泄漏的属性与对应 widget,据此重写本单范围。审计只读、零文件冲突,与在飞的 rc.2 pin-bump PR 并行。

    裁决其余部分不变:路线 1(共享 toDomProps)+ 同 PR 必须带契约测试(渲染每个注册 widget,断言无未知小写属性落到 DOM),路线 3 否决。


    Generated by Claude Code


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    ContributorAuthor

    审计结果 —— 本单三条前提被实测推翻,范围以本评论为准

    只读审计已完成(origin/main @ 91757a79f)。方法:真表单渲染器 + 真 widget,47 个注册 widget × 7 种 fixture 变体,外加独立的 SchemaRenderer(SDUI)走查;判定器在使用前做了双向验证(12 元素标准标记 fixture 上 0 误报,17 个植入的伪属性 17/17 检出)。审计未提交任何代码,工作树已清理。

    推翻的前提

    1. 头号症状 schema="[object Object]" 已不复现 —— 两条渲染路径上都归零。不是靠结果推断,是读代码确认了两道各自独立、任一道单独成立即可的封口:stripRegisteredFieldProps(form.tsx:275-289)解构掉 schema,且 form.tsx:1889-1904 不再显式回传;withFieldCarrier(withFieldCarrier.tsx:69-71)消费 schema 只转发 field —— 后者正是 SDUI 路径上唯一的防线。#3233 确实把它修掉了。

    2. 「~80 个 widget」是 14 个文件。 注册表实测共 47 个 field widget(ComponentRegistry.getAllTypes() 的 field: 命名空间),其中裸展开泄漏的 13 个,加上结构相同但目前被 Radix 吞掉的 SelectField 共 14 个文件,全在 packages/fields/src/widgets/ 下。原估计比真实改动集大 5.7 倍,且超过了注册 widget 总数本身 —— 按 ~80 派单的人会去找 66 个不存在的文件。

    3. 真正的大头不是 error,是「作者字段配置的开放尾巴」。 error 影响 7 个 widget(text / number / date / datetime / time / password / boolean)。但作者在字段配置上写的任意额外键会全部落到 DOM,影响 13 个 —— 而且正是这条尾巴还在复现本单命名由来的那个 [object Object]:

    <input class="…" placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
           zzcanarynum="42" zzcanarycamel="CANARY-CAMEL" id="…" type="text" value="" name="f">
    

    机制:form.tsx:1067-1085 只解构固定的一组已知键,其余 ...fieldProps 原样转发(:1385),而 stripRegisteredFieldProps 是按名删除、没有白名单。实测一个 lookup 字段上作者写的 reference_to: 'contacts' 会作为同名 prop 抵达 widget。

    因此:toDomProps 必须是白名单,不能是黑名单

    这是本次审计最有后果的一条输入。一个枚举当下渲染器专用键的黑名单(schema / error / emptyHint / dataSource / dependentValues / dependsOn / options / inputType / …)会放行本次审计的每一个 canary,也挡不住 SDUI 路径上的 label。只有「保留已知安全键 + data-* + aria-*」能封住。

    好消息:这个白名单已经写好了 —— packages/fields/src/widgets/types.ts 的「DOM pass-through」段落已声明了确切集合(id / name / autoFocus / tabIndex / onBlur / onFocus / onClick + AriaAttributes + data-${string} 索引签名)。toDomProps 就是这段声明的运行时执行体,两者因此不会漂移,#3221 那个「封闭的类型但运行时可被违反」的论证也才第一次有了牙齿。

    本单未审视的一整条路径:SDUI 更宽

    本单通篇只推理表单渲染器。实测 SchemaRenderer 路径泄漏严格更多 —— 多出 label,且作者节点上的任意键都会漏(SchemaRenderer.tsx:459-461 把整个节点摊成 props,该路径没有 stripRegisteredFieldProps 等价物)。这条路径上 widget 自己的展开是唯一防线,这是白名单形状的第二个独立论据。

    契约测试:可行且便宜,但有四个坑

    审计用的测量脚手架基本就是这个测试:47 widget × 7 变体 + SDUI 走查,light dom 项目下 ~17 秒跑完,连跑 4 次零 flake,不需要进 heavyDomTests。落点 packages/fields/src/__tests__/(widget-aria-invalid-e2e.test.tsx 已有先例)。四个坑:

    1. 不能靠 React 警告。React 19 对全小写未知属性完全静默——整轮 sweep 里唯一的警告来自 camelCase canary,而且它照样把属性写进了 DOM。必须走 DOM 枚举。
    2. 必须两条路径都渲染。只测表单路径会把 SDUI 的洞报成干净的(label 就是这么漏掉的)。
    3. 每个类型要有「真能触发校验失败的值」表,且要先断言错误信息确实渲染了。审计自己就中过这个招:required 的 boolean 默认 false 能通过 react-hook-form 的 required 规则,于是错误从不产生、泄漏从不渲染 —— 第一轮因此把 error 泄漏数报成了 6,真值是 7。变体若静默没产生错误,测试必须失败而不是通过。
    4. 本仓跑的是 happy-dom,不是 jsdom(vitest.config.mts)。happy-dom 的 IDL 有实测到的缺口(HTMLSelectElement 无 size、HTMLOptionElement 无 label),判定器需要一个极小的例外表并注明每条的理由。另需按前缀放行 cmdk-*(第三方库自己盖在自己 DOM 上的标记,非 prop 透传)。

    实测结论:没有任何 widget 真的需要自定义属性 —— 47 个里一个都没有。声明的契约本来就只开放 data-${string} 一族,所以「只有 data-* 是开放的」今天就可执行。

    定稿范围

    • 机械改动 14 个文件(13 个泄漏 widget + SelectField.tsx),全在 packages/fields/src/widgets/
    • 新增 2 个文件:toDomProps 助手(从 packages/fields/src/index.tsx 导出,理由同已导出的 withFieldCarrier)+ 契约测试
    • form.tsx 不需要改 —— 实测它的 strip 层对它该负责的部分已经正确,剩余泄漏 100% 在 widget 侧。本单正文那句「+ 可能 packages/components/src/renderers/form/」可以划掉,别去重开它
    • 不分批:14 个文件同包,一个 PR 合适;拆开会让必须一次性覆盖整个注册表的契约测试与它守护的修复分离落地。原裁决的「按包分批」按此调整

    顺带发现(另立单,不夹带进本单 PR)


    Generated by Claude Code


    Generated by Claude Code

  4. added 3 commits that reference this issue on Aug 4, 2026
    19b8c9b
    8d8094a
    c97a45e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingpm:queue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions