Skip to content

fix(app-shell): InspectorComboField 的 label 关联 trigger,无名 combo 不再可编译 (#3997) - #4012

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3997-inspector-combo-label
Aug 10, 2026
Merged

fix(app-shell): InspectorComboField 的 label 关联 trigger,无名 combo 不再可编译 (#3997)#4012
yinlianghui merged 1 commit into
mainfrom
claude/issue-3997-inspector-combo-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3997

第四个字段原子,与 PR #3996 修掉的 _shared.tsx 三个原子形状完全相同:Label 是控件的兄弟节点,没有 htmlFor;trigger 没有 id,也没有 aria-label 兜底。它住在自己的模块里,所以那三个修好之后它仍旧带着缺陷。标签与 button[role=combobox] 之间只有视觉邻接:焦点落上去读到的是匿名 combobox,可见标签是一段无归属文本,getByLabelText 到不了它,点标签什么都不会发生。

非测试调用点 18 处(对象字段 / 数据集 / 仪表盘部件 / 应用导航 / 视图变体检查器 —— lookup 显示与描述字段、lookupFilters 行、汇总聚合、数据集维度与度量、导航目标),打开任一面板即渲染,不是 dormant。

带 label 分支

#3994 的定型机制闭合:React.useId() 在原子内部生成 id,LabelhtmlFor,id 落到 PopoverTrigger asChild 渲染出的那个 Button

不落 Popover —— Radix Popover.Root 是纯 context provider,不渲染任何 DOM 元素,给它的 id 会被静默丢弃、for 随之悬空。这是 #3976(PR #3992)与 #3994(PR #3996)已经付过两次学费的同一机制,所以这次把「落点在 DOM 真实元素上」单独钉成一条断言,而不是靠注释约束。

无 label 分支:命名改成类型级要求,而不是消费端兜底

label 原本可选,无 label 分支是同缺陷更重一档 —— combobox 完全无名(既没有 for 也没有 aria-label),而 18 处调用点里有 5 处正是这么写的。

没有采用宽松兜底:用 placeholder 合成名字会把「Select…」念成字段名,而「消费端容忍」正是 AI 写出的元数据错误藏身与繁殖的地方。改为「三条通道恰选其一」,零条与两条都不可编译:

通道 何时用 本 PR 里的站点
label 原子渲染可见标签并自持关联 已传 label 的 13 处,不变
ariaLabel 重复行里本就没有可见标签,加一个会破坏栅格 应用导航 URL 过滤条件的 field = value 行、数据集 join 列表、依赖查找的「添加字段」选择器、仪表盘部件的按过滤器绑定行
id 外部 Label htmlFor 已经持有命名 DashboardWidgetInspectorwidget-dataset

id 这一条是被真实站点逼出来的:DashboardWidgetInspector 用一个 Field 包装器渲染 Label htmlFor={id} 并把同一个 id 交给被包控件,其余字段都履行了这个约定(Input idSelectTrigger id),只有 dataset combo 落不下去,因为原子根本不收 id。那个 for 指向一个没有任何元素持有的 id —— 悬空 IDREF,比无标签更糟,因为工具会报告一个解析不到的关联。

两条通道同时给出是 #3961/#3978 要避免的重复播报,故一并禁掉。

之所以把它做成类型而不是运行期守卫:两种错误都没有组件能自行发现并报告的运行期症状 —— 无名 combobox 渲染、布局、提交值全都正常,只对看不见它的用户是错的。所以检查只能发生在编写期,否则就不存在。

反向验证(先预判方向,再跑变异;变异均未提交)

三个方向都实测了,其中第三个的预判是错的,写在这里而不是抹平:

  1. htmlFor → 预判 combo 各行翻红。实测 8 行红,其余三原子与 InspectorCheckboxField 保持绿。预判里特意点出「mints exactly one owner for the id 应当保持绿」(id 还在 Button 上,只是没人指向它)—— 实测确认,该行是这一变异唯一测不到的行。
  2. id 改落 Popover Root → 预判 combo 各行红,且外部 id 分支一并红。实测 11 行红:上面 8 行 + mints exactly one owner(id 被整个丢弃,owner 计数 1 到 0)+ 外部 id 分支 + DashboardWidgetInspector 真实调用点的钉子。Radix 不报任何警告,静默丢弃如注释所述。
  3. 命名改回可选 → 预判 typecheck 因两条 unused @ts-expect-error 翻红。实测完全绿 —— 假绿。 第一稿把 @ts-expect-error 写在 _shared.labels.test.tsx 里,而包的构建 tsconfig 排除 **/*.test.tsx、vitest 又擦除类型,没有任何编译器读它:那两条指令既不会在错误消失时失败,也不会在错误从来不存在时失败。这正是 build(types): type-check this package's tests, so the spec-derivation guards actually run (objectstack#4074) #3009 的失效模式(断言从未运行,而文件头声称它们是「真正的强制」),tsconfig.typetests.json 自己的头注释也在警告同一件事。于是类型级断言移入独立文件 InspectorComboField.naming.types.test.tsx 并列入 tsconfig.typetests.json;同一变异现在报 6 条错(4 条 assignability 断言 + 2 条 unused @ts-expect-error)。

钉子

  • _shared.labels.test.tsx —— 第四原子直接加入既有的 describe.each(它已把「for 有宿主 / 命中可聚焦控件 / 可访问名 / 单一命名通道 / 多实例不撞车」写成与组件无关的形状,这正是按名册而非按组件写的回报),另加四组:trigger 落点(含 owner 计数)、自定义值仍有名、ariaLabel 分支、外部 id 分支。
  • DashboardWidgetInspector.test.tsx —— 真实调用点上钉 Dataset 标签解析到 combo trigger。刻意只锁这一对,没有做整面板「无悬空 for」扫描:widget-colorColorVariantPicker 同样不收 id,那个 for 也悬空、且它的 radiogroup 完全无可访问名 —— 另一个组件、本单范围外,已单独立单 [app-shell] DashboardWidgetInspector 的 Field(id="widget-color") 的 for 悬空 —— ColorVariantPicker 不收 id,颜色 radiogroup 完全无可访问名 #4010(未认领,交 triage)。整面板扫描会把它锁成红,或更糟,锁成一条将来被「修绿」的假断言。
  • InspectorComboField.naming.types.test.tsx(新增)—— 类型级断言,来历见上。

范围

主体是 InspectorComboField 这一个已核实站点 + 其无 label 分支的命名兜底。issue 正文附的 18 处宽面清单不在本 PR(逐处「Label 下面到底是不是控件、还是纯分组小标题」未核)。实施中顺手核实到的两处判断已回传给 PM,代码未动。

新增一对 i18n 字符串(engine.inspector.widget.filterBindingField,en-US + zh-CN),给仪表盘部件里按过滤器的绑定 combo 命名 —— 它上方那个标题同时统辖一个 checkbox 和这个 combo,是行级标题而非控件标签,故不去改动它的归属(那正属于宽面 sweep 要裁定的事)。

验证

  • pnpm exec vitest run packages/app-shell --maxWorkers=2 —— 312 文件 / 2921 通过 / 1 跳过。
  • 仓根 pnpm exec turbo run type-check --concurrency=2 —— 78/78 成功。
  • node scripts/check-control-bytes.mjscheck-type-check-coverage.mjscheck-i18n-call-site-keys.mjscheck-i18n-en-drift.mjs 全绿;@object-ui/app-shell lint 0 error。

Generated by Claude Code

…#3997)

第四个字段原子,与 PR #3996 修掉的 `_shared.tsx` 三个原子形状完全相同 ——
`Label` 是控件的兄弟节点,没有 `htmlFor`,trigger 没有 `id` 也没有 `aria-label`。
它在自己的模块里,所以那三个修好之后它仍旧带着缺陷。标签与
`button[role=combobox]` 之间只有视觉邻接:焦点落上去读到匿名 combobox,可见标签是
一段无归属文本,`getByLabelText` 到不了它,点标签什么都不会发生。非测试调用点 18 处
(对象字段 / 数据集 / 仪表盘部件 / 应用导航 / 视图变体检查器),打开任一面板即渲染。

带 label 分支照 #3994 的定型机制闭合:`React.useId()` 在原子内部生成 id,`Label` 补
`htmlFor`,id 落到 `PopoverTrigger asChild` 渲染出的那个 `Button`。⛔ 不落 `Popover`
—— Radix `Popover.Root` 是纯 context provider,不渲染 DOM,给它的 id 会被静默丢弃、
`for` 随之悬空(#3976 / #3994 已为此付过两次学费)。

## 无 label 分支:命名改成类型级要求,而不是消费端兜底

`label` 原本可选,无 label 分支是同缺陷更重一档 —— combobox 完全无名,而 18 处调用点
里有 5 处正是这么写的。没有采用宽松兜底(用 placeholder 合成名字会把「Select…」念成
字段名);改为「三条通道恰选其一」,零条和两条都不可编译:

- `label` —— 原子渲染可见标签并自持关联。已传 label 的 13 处不变。
- `ariaLabel` —— 重复行里本就没有可见标签、加一个会破坏栅格:应用导航 URL 过滤条件的
  `field = value` 行、数据集的 join 列表、依赖查找的「添加字段」选择器。
- `id` —— 外部 `Label htmlFor` 已经持有命名。`DashboardWidgetInspector` 的 `Field`
  包装器渲染 `Label htmlFor={id}` 并把同一个 id 交给被包控件,其余字段都履行了这个约定
  (`Input id`、`SelectTrigger id`),只有 dataset combo 落不下去,因为原子不收 id ——
  那个 `for` 指向一个没有任何元素持有的 id,是悬空 IDREF,比无标签更糟,因为工具会报告
  一个解析不到的关联。

两条通道同时给出是 #3961/#3978 要避免的重复播报,故也一并禁掉。两种错误都没有组件能
自行发现并报告的运行期症状 —— 无名 combobox 渲染、布局、提交值全都正常,只对看不见它
的用户是错的 —— 所以检查只能发生在编写期,否则就不存在。

## 钉子与反向验证

- `_shared.labels.test.tsx`:第四原子直接加入既有的 `describe.each`(它已把「for 有
  宿主 / 命中可聚焦控件 / 可访问名 / 单一命名通道 / 多实例不撞车」写成与组件无关的形
  状),另加 trigger 落点、自定义值、`ariaLabel` 分支、外部 `id` 分支四组。
- `DashboardWidgetInspector.test.tsx`:真实调用点上钉 Dataset 标签解析到 combo trigger。
  刻意只锁这一对,不做整面板「无悬空 for」扫描 —— `widget-color` 的
  `ColorVariantPicker` 同样不收 id,那是另一个组件、本单范围外,已单独立单 #4010。
- `InspectorComboField.naming.types.test.tsx`(新增,列入 `tsconfig.typetests.json`):
  类型级断言。第一稿把 `@ts-expect-error` 写在 `_shared.labels.test.tsx` 里,变异实测
  显示那是**假绿** —— 把命名改回可选,`tsc --noEmit` 完全通过,因为包的构建 tsconfig
  排除 `**/*.test.tsx`、vitest 又擦除类型,没有任何编译器读它。这正是 #3009 的失效模式,
  故移入被 typetests 项目编译的独立文件;同一变异现在报 6 条错(4 条 assignability
  断言 + 2 条 unused `@ts-expect-error`)。

反向验证三向(先预判后跑,变异未提交):撤 `htmlFor` → combo 8 行翻红,其中「mints
exactly one owner for the id」按预判保持绿(id 还在,只是没人指向它);id 改落 `Popover`
Root → 11 行翻红(多出 owner 计数、外部 id 分支、真实调用点钉);命名改回可选 → 运行期
全绿、信号只在 typetests。

Fixes #3997

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 12:18am

Request Review

@github-actions github-actions Bot added the tests label Aug 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-DgD_xcQa.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 483.72KB 106.71KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 228.44KB 56.66KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.84KB 10.80KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.49KB 17.48KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.67KB 30.69KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 113.19KB 27.36KB
plugin-gantt (index.js) 162.79KB 39.67KB
plugin-grid (index.js) 187.97KB 49.79KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.73KB 26.55KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3997 落地 —— 检查器第四个字段原子 InspectorComboField 闭合,a11y label 家族在 metadata-admin 面收口。

核验记录(head 实物 + CI 亲读):

  1. 带 label 分支照 metadata-admin 检查器的三个共享字段组件把 Label 和控件放在一起但不关联 —— 屏幕阅读器读到的是"无名编辑框",可见标签成了孤立文本 #3994 定型机制:useId + htmlFor,id 落 PopoverTrigger asChild 渲染的真实 Button,不落 Radix Root;变异 2(id 改落 Popover Root)11 行红、Radix 零警告静默丢弃 —— 落点断言被证实非空转。
  2. 无 label 分支的类型级三通道 union 接受:label / ariaLabel / id 恰选其一,零通道与双通道均不可编译。理由充分 —— 两类错误都没有运行期症状可自检,只能编写期结构性阻止;契约在生产端与仓风一致。5 处调用点按真实形状补通道是类型收紧的必然连带,18 处宽面清单仍未动(边界遵守)。id 通道由真实站点(widget-dataset 外部 Label 已持有命名)逼出,类型注释写明了使用边界。
  3. 亮点:反向验证第三向预判错误如实上报并自纠 —— @ts-expect-error 首稿写在包构建排除、vitest 擦除类型的测试文件里,tsc 完全假绿(build(types): type-check this package's tests, so the spec-derivation guards actually run (objectstack#4074) #3009 失效模式,typetests 头注释警告的正是这件事);移入 tsconfig.typetests.json 项目后同一变异 6 条错,钉子才真正成立。这类「钉子自身有效性」的自检是本仓最看重的纪律。
  4. 顺手核实的两处分组标题判定(DashboardWidgetInspector:337、ObjectFieldInspector:1135)理由在案未扩围;out-of-scope [app-shell] DashboardWidgetInspector 的 Field(id="widget-color") 的 for 悬空 —— ColorVariantPicker 不收 id,颜色 radiogroup 完全无可访问名 #4010(widget-color 悬空 for + radiogroup 无名)已由 PM 晋级 queue,其回传的「悬空 IDREF 检索式」方法论提示已记入该单分诊评论。
  5. 门与规程:app-shell 312 文件 2921 绿(+1 type-test 文件);i18n 两道门绿(新 key 双语);覆盖门含新 typetests 条目;type-check 78/78 双段;控制字节门+自扫;changeset patch;fable 0;releases 零触碰。
  6. CI 亲读终态:20/20 completed、0 失败(Test shard×4 至 00:24:48Z;coverage/dependabot path-filter skipped)。

Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 01:22
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 1037e1a Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3997-inspector-combo-label branch August 10, 2026 01:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

检查器第四个字段原子 InspectorComboField 的 label 仍与 trigger 不关联 —— #3994 修了三个,这个同族站点没在围栏内

2 participants