Skip to content

fix(fields,components): 字段 widget 的校验槽位改用 spec 的 error,并接上生产者 (#3222) - #3289

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3222-field-widget-error-contract
Aug 3, 2026
Merged

fix(fields,components): 字段 widget 的校验槽位改用 spec 的 error,并接上生产者 (#3222)#3289
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3222-field-widget-error-contract

Conversation

@xuyushun441-sys

@xuyushun441-sysxuyushun441-sys commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#3222

按 PM 裁定的方向 1(objectui 跟随 spec),并且是「改名 + 接上生产者」两件一起。packages/spec 一个字未改。

说明:下文所有 JSX 组件名与标签都写成 < FormControl > 这样尖括号后带一个空格的形式 —— GitHub 的正文消毒器会把 < 紧跟字母当成 HTML 标签在存储时剥掉。本 PR 正文第一版就因此被吃掉了五处组件名。

这修的不是命名,是一个当下就存在的可访问性缺陷

issue 的实测把这单的性质改了:

全仓 packages/ + apps/ 中 errorMessage 的生产者:0 处
packages/fields/src 中 errorMessage 的读取 :15 处(7 个 widget)
读 props.error 的代码 :0 处

这个槽位在两种拼写下都是死的。 后果比「纸面不一致」严重一档 —— 而且比 issue 里描述的还要糟:

< FormControl > 是 Radix Slot,它本来就把正确的 aria-invalid 递给子元素。但 widget 自己的 aria-invalid={!!errorMessage} 写在 {...props} 展开之后,所以它覆盖了 Slot 给的正确值。也就是说这 7 个 widget 不只是「没拿到信号」,而是主动把对的值改写成了 false

变异验证时抓到的真实 DOM 就是这个现场 —— aria-describedby 已经指向了错误消息(表单知道这个字段挂了),而 aria-invalidfalse:

< input
aria-describedby="_r_0_-form-item-description _r_0_-form-item-message"
aria-invalid="false" ← widget 用 !!undefined 覆盖掉了 Slot 的 true
type="email" name="email" value="" >

反证也成立:TextField 从不自己算 aria-invalid,Slot 的值原样到达,它一直是对的。坏的恰好只有那 7 个自己算的。这条已作为测试钉住。

改了什么

  1. 表单渲染器开始生产这个 prop(实质工作):渲染已注册 widget 时把 react-hook-form 的 fieldState.error?.message 作为 error 传下去。
  2. 槽位采用 spec 的名字:errorMessageerror,FieldWidgetComponentProps + 7 个 widget,不保留别名FieldWidgetComponentProps[key: string]: any 让每个 widget prop 检查失效 #3221 已删索引签名,所以漏改点是编译错误 —— 改名的正确性由编译器验证,不是靠 grep。
  3. 职责划清:widget 只用 error 驱动 aria-invalid;校验文案仍由 < FormMessage / > 渲染。有测试钉住「文案只出现一次」。
  4. 内置字段类型不接收 error:它们直接渲染在 < FormControl > 里,Slot 已给 aria-invalid,这里没有读者,传下去只会变成 DOM 上一个多余的 error="…" 属性 —— 所以在 stripRendererOnlyProps 里剥掉。

required:评估后维持现状,不下沉

按要求单独评估,结论是不接,理由不是「工作量大」:

  • 必填标记今天只有一个作者 —— 渲染器的 < FormLabel >(那个带 aria-label="required"*,label 关联会把它并进控件的可访问名)。把 flag 递给 widget 就等于给这个标记第二个作者,下一个 AI 写的 widget 很自然会自己画一个星号 —— 这正是本 PR 里让校验文案留在 < FormMessage / > 的同一个「双份显示」理由。两处应当同一结论。
  • 它也不是拿到 aria-required 的必要条件:FieldWidgetComponentProps 已经 & AriaAttributes,aria-required 本来就是这份契约里已声明、已类型化的键,而且每个 widget 都已经把剩余 props 转发到控件上。也就是说「让 required 状态到达 DOM」不需要新增任何契约键,更不需要 48 个 widget 各自记得把布尔翻译成属性。
  • 所以「接 required」和「拿到 aria-required」是两件事,只有后者是 a11y 修复,而后者是一个独立的一行改动,不依赖本 PR。已另开 issue 记录,未指派。

结论:接了会让契约更差(多一个双份显示的入口),不接也不妨碍真正的 a11y 目标。 不为对齐而对齐。

测试

  • packages/components/src/renderers/form/__tests__/form-error-delivery.test.tsx —— 生产者半边(探针 widget 刻意展开剩余 props,否则 Slot 的 aria-invalid 会让测试假绿)。
  • packages/fields/src/__tests__/widget-aria-invalid-e2e.test.tsx —— 端到端:真表单 + 真 widget + 真 react-hook-form 校验,7 个 widget 各一条「失败后 aria-invalid 为 true、失败前为 false」。
  • 现有 validation-feedback / widget-props-contract / spec-symbol-batch7 的钉子随之翻面(_ErrorIsAbsent 撤销,新增 _OldNameRetired 钉住没有保留别名)。

变异验证(硬要求):撤掉 error: fieldState.error?.message 一行后,15 条里 12 条转红;恢复后 61 文件 / 615 测试全绿。

已知取舍

不读 error 的已注册 widget 会把它当剩余 prop 展开到 DOM,失败时留下 error="T is required" 属性(实测 0 条 React 警告,因为是全小写)。这是有意的:按 widget 类型做白名单会对第三方 widget 重建「声明了但没交付」,而契约恰恰是为它们存在的。同一个 DOM 上早就有更严重的一例(schema="[object Object]"),已另开 issue。

文档同步

  • content/docs/guide/plugin-development.md / skills/objectui/guides/plugin-development.md:改名 + 新增「谁渲染什么」的职责表;顺带修掉 skill 里仍在教的 [key: string]: any(FieldWidgetComponentProps[key: string]: any 让每个 widget prop 检查失效 #3221 已删)。
  • .github/prompts/component.prompt.md:除改名外修掉 issue 点名的另两处 —— 把 spec 的非泛型类型别名当泛型用(FieldWidgetProps< number >),以及解构一个两边都不存在的 mode prop。
  • content/docs/protocol/objectui/widget-contract.mdx在 objectstack 仓,不在本仓 —— 已另开 issue 跟进(它的示例目前教 widget 自己渲染文案,按本 PR 的结论应改成只驱动 aria-invalid)。

Changeset:minor(按仓库版本策略,objectui 自身的破坏性变更也标 minor),正文含 FROM → TO 迁移指引。

🤖 Generated with Claude Code

https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa

…as `error` (#3222)
`@objectstack/spec/ui`'s `FieldWidgetPropsSchema` declares `error?: string`;
`@object-ui/fields` declared `errorMessage?: string`. That looked like a naming
split, but the slot was dead under BOTH spellings: nothing in packages/ or
apps/ ever produced it, so the seven widgets computing
`aria-invalid={!!errorMessage}` computed it from a permanent `undefined`.
`aria-invalid` had never once been set on a failing field — and because a
widget writes its own attribute AFTER the props spread, those seven actively
OVERWROTE the correct `aria-invalid` that `<FormControl>`'s Radix Slot hands
down.
Two halves, both required:
- the form renderer now passes `fieldState.error?.message` down as `error`
when it renders a registered widget (the producer that never existed);
- the slot adopts the spec's name across `FieldWidgetComponentProps` and the
7 widgets, with no alias kept. #3221 had already closed the type, so the
compiler — not grep — validated the rename.
Responsibilities stay split: the widget consumes `error` only to drive
`aria-invalid` on the control it renders; the message text remains with
`<FormMessage/>`. `required` is deliberately NOT lowered into widget props —
the required marker has exactly one author (`<FormLabel>`), and the a11y state
a widget could carry is `aria-required`, which needs no contract change.
Builtin field types strip `error` instead: they render inside `<FormControl>`,
whose Slot already supplies `aria-invalid`, so the prop would only reach the
DOM as a stray attribute.
Docs synced (plugin-development guide + skill, component.prompt.md — which also
used the spec's non-generic alias as a generic and destructured a `mode` prop
that exists on neither type).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
@vercel

vercelBot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 3, 2026 10:59am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-Cqg1Lpo5.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.47KB3.09KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)476.47KB104.64KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)223.48KB54.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)37.96KB10.54KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)60.54KB17.13KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.01KB28.86KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)230.56KB56.80KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.49KB26.95KB
plugin-gantt (index.js)162.25KB39.55KB
plugin-grid (index.js)185.08KB49.04KB
plugin-kanban (index.js)47.89KB13.18KB
plugin-list (index.js)104.94KB25.32KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.55KB10.59KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.34KB2.82KB
plugin-view (index.js)83.67KB20.43KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 11:13
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queueAug 3, 2026
Merged via the queue into main with commit 56409c2Aug 3, 2026
17 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-3222-field-widget-error-contract branch August 3, 2026 11:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

字段 widget 的错误提示键:spec 声明 error,objectui 渲染 errorMessage(declared ≠ enforced)

2 participants

@xuyushun441-sys@claude