Skip to content

fix(components): 只读注册字段的替换显示接上 host 的命名与描述通道 (#4788) - #4862

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4788-readonly-host-wrapper
Aug 16, 2026
Merged

fix(components): 只读注册字段的替换显示接上 host 的命名与描述通道 (#4788)#4862
yinlianghui merged 2 commits into
mainfrom
claude/issue-4788-readonly-host-wrapper

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4788

按 2026-08-16 维护者裁定(A / A / E,评论 5307576062)实施:只读态 host label 去 for 改发 id;只读值面接受 role="group";机制落 packages/components/src/renderers/form/form.tsx 一处包容器。packages/fields/src/widgets/ 下零改动 —— 这是 E 方案的结构卖点。

缺陷(复跑的实测读数)

注册 field widget 的只读分支渲染的是「替换显示」(mailto 锚、格式化 span、chip 行、预览表),并且在自己的 DOM pass-through 之前提前 return,所以 host 下发的东西一件都没落到元素上。在 origin/main1ef236e18 上复跑测量评论的 probe(真 form renderer + 裸注册,每字段带 description: 'Some help',一行一个字段):

 readonly editable
text for=DANGLING hostIdEl=NONE consumers=0 for=RESOLVES-LABELABLE hostIdEl=input consumers=1
number for=DANGLING hostIdEl=NONE consumers=0 for=RESOLVES-LABELABLE hostIdEl=input consumers=1
boolean for=DANGLING hostIdEl=NONE consumers=0 for=RESOLVES-LABELABLE hostIdEl=switch consumers=1
email for=DANGLING hostIdEl=NONE consumers=0 for=RESOLVES-LABELABLE hostIdEl=input consumers=1
phone for=DANGLING hostIdEl=NONE consumers=0 for=RESOLVES-LABELABLE hostIdEl=input consumers=1
url for=DANGLING hostIdEl=NONE consumers=0 for=RESOLVES-LABELABLE hostIdEl=input consumers=1
formula for=DANGLING hostIdEl=NONE consumers=0 for=DANGLING hostIdEl=NONE consumers=0
…(34 个注册的非 group-labelled 类型,只读行逐字相同)

hostIdEl=NONE 是关键读数:只读态下 …-form-item 这个 id 整个文档里没有任何元素带,所以可见 label 的 for 悬空、只读面在无障碍树里没有名字,同时渲染出来的帮助文本零消费者。D 组四件(formula / summary / auto_number / vector)没有可编辑分支,整个 widget 就是替换显示,两列读数相同。

修复后同一 probe 的只读列(36 行,逐字相同):

for=NO-FOR labelId=SET hostIdEl=div[group] consumers=1 ariaInvalid=(absent) name="Label email user@example.com"

36 行 editable 读数改动前后 diff 为空。

修法

form renderer 在「readonly + 有 label + 非 group-labelled + 解析到注册 field widget」时,把 widget 的渲染结果包进一个容器,同时 label 发 id 去 forFormControl 是 Radix Slot,于是它原本注给 widget 根元素的 id / aria-describedby / aria-invalid 改注到这一层,widget 自己渲染的标记一个字节都没变。

容器上:

aria-required 根本到不了这一层 —— 它照旧随 widget props 走,理由相同。

为什么落 host 而不是 33 个 widget

测量评论用受控实验否掉了 widget 侧的写法:aria-labelledby 落在无 role 的 span / div 上在 ARIA 上惰性,而 jsdom 里 toHaveAccessibleName() 对这份标记答 PASS。照那条路走会得到 26 个惰性面 + 一条把它们认证下来的钉子测试 —— 本仓视为一等失败的「declared but not delivered」。落在 host 一处之后,「下一个作者忘了 spread」这个入口不存在了,当下 34 个和将来任意第三方 widget 一律正确。

也因此本 PR 的断言全部写成 DOM 归属:每个 IDREF 都用 getElementById 解析,再断言解析到的节点是正确 form item 内的正确元素,而不是靠 toHaveAccessibleName —— 它分不出真关联和惰性关联。

未改变的路径(逐条钉住)

内建类型(input / textarea / checkbox / switch / select)、可编辑字段、group-labelled widget、无 label 渲染的字段:逐字节不变。

两个需要说清的后果

  1. 只读注册字段多一层 DOM。按 widget 根元素是 form item 直接子元素来写的 e2e 选择器会看到它;该层带 data-slot="readonly-field-group" 作为稳定定位器(ADR-0054 C4)。
  2. 那一层是块级盒,而好几个只读面原本是行内元素 —— 于是这些行现在拿到表单标准的 label 到值间距,与可编辑态一致。

一处如实记录的测试自我修正

readonly-host-plumbing-e2e.test.tsx 里「widget 输出在容器内」这条,第一版断言写的是 textContent 非空,对 image / signature变红:它们的只读面渲染的是 img,值在 alt 里,文本为空。断言换成了更强也更贴题的一条 —— 容器的可访问名必须超出 label 本身(Label image a.png / Label signature Signature),这正是复合命名要保证的事。读数在先,断言在后。

验证(全部实跑)

新增两个测试文件:

  • packages/components/src/renderers/form/__tests__/form-readonly-host-group.test.tsx —— host 机制钉子(合成 widget:锚面 / 纯文本面 / display-only 面 / group-labelled 面 / 裸名 SDUI 组件),含容器归属、控件通道边界、五条「什么都没变」;
  • packages/fields/src/__tests__/readonly-host-plumbing-e2e.test.tsx —— 34 个真 widget 全量扫(每个三条),加 email / text / boolean / formula 四类抽样,再加四条可编辑态回归。
vitest run packages/components packages/fields packages/plugin-form --maxWorkers=2
Test Files 284 passed (284)
Tests 3362 passed (3362) SWEEP_EXIT=0
vitest run packages/fields/src/__tests__/readonly-host-plumbing-e2e.test.tsx
Test Files 1 passed (1)
Tests 116 passed (116)
turbo run type-check --concurrency=2 --filter=@object-ui/components --filter=@object-ui/fields
Tasks: 12 successful, 12 total TC_EXIT=0
node scripts/check-control-bytes.mjs OK (scanned 4343 tracked text files)
node scripts/check-changeset-presence.mjs / -no-major / -fixed OK
grep -naP 控制字符自查(四个改动文件) 零命中

反向验证(方向先判后跑,预判写在跑之前)

先 commit,再把包容器逻辑临时摘掉(withReadonlyHostGroup 恒等返回,label 发 id 那半故意留着,所以唯一被删的就是容器);git checkout 对着文件路径还原,未用 stash

预判:方向是,不是反转 —— 断言的是一个被实验删掉的容器的存在与归属;而「什么都没变」那组断言的是该容器在未受影响路径上的不存在,所以必须保持绿。逐条点名 8 条应红、5 条应绿,并预测 fields 侧 112 红 / 4 绿。

实跑,与预判逐条一致:

form-readonly-host-group.test.tsx 13 tests | 8 failed (8 条名字与预判完全相同)
readonly-host-plumbing-e2e.test.tsx 116 tests | 112 failed
Test Files 2 failed (2)
Tests 120 failed | 9 passed (129) REV_EXIT=1

保持绿的 9 条正是「什么都没变」那 5 条 + fields 侧 4 条可编辑态回归。

范围外


Generated by Claude Code

…ibed host container (#4788)
A registered field widget's readonly branch renders a replacement display and
returns before its DOM pass-through, so nothing the form renderer handed down
reached an element. Measured on origin/main at 1ef236e with a real form, one
field per row, description set: the host control id was on NO element in the
document, so the visible label's `for` dangled and the readonly surface had no
accessible name at all, while the rendered help text had zero consumers. All 34
registered non-group-labelled types read identically, the four display-only ones
(formula / summary / auto_number / vector) included.
Per the maintainer's 2026-08-16 ruling (A / A / E), the mechanism lands once in
the host: when a readonly field resolves to a registered field widget, the form
renderer wraps its output in a container carrying the host id, `role="group"`, a
composite `aria-labelledby` (label id + own id, so the VALUE stays in the
accessible name) and `aria-describedby`, and the label publishes an id in place
of its `for`. `aria-invalid` is dropped at that boundary — control-channel state
has no meaning on a surface the reader cannot edit (#3291 / #3318 / #4005).
No widget file changed. That is the structural claim: there is no "remember to
spread the host props" entry point left for the next widget author to miss, and
the measurement showed why a widget-side fix was the wrong shape — an
`aria-labelledby` on a role-less span names nothing while jsdom's
`toHaveAccessibleName()` passes it.
Builtin types, editable fields, group-labelled widgets and label-less fields are
untouched, byte for byte.
Co-authored-by: Claude <noreply@anthropic.com>
)
The first spelling of "the widget output sits INSIDE that group" asserted
non-empty `textContent`, and measured RED on exactly two of the 36 types:
`image` and `signature` render an `img` whose value lives in its `alt`, so
their text is empty while their accessible names read `Label image a.png` and
`Label signature Signature`.
The corrected assertion is the stronger one and the one this issue is actually
about: the group's accessible name must extend past the label, which is what
the composite `aria-labelledby="labelId hostId"` exists to guarantee. It holds
for every face — text, anchor, placeholder copy, and the two image ones.
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-C-c19FgJ.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
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)38.46KB10.17KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
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)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
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)495.70KB110.25KB
core (index.js)3.79KB1.52KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)157.05KB43.28KB
fields (index.js)231.65KB57.57KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.96KB10.85KB
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.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.86KB12.91KB
plugin-charts (index.js)64.44KB18.28KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.14KB32.58KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)239.81KB59.97KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)115.34KB27.74KB
plugin-gantt (index.js)164.30KB40.02KB
plugin-grid (index.js)192.23KB51.30KB
plugin-kanban (index.js)52.72KB14.54KB
plugin-list (index.js)111.23KB26.97KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.38KB11.09KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.53KB9.41KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.28KB0.68KB
react (schema-input.js)1.45KB0.83KB
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 (dashboard-filter-alias.js)6.23KB2.74KB
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)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
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

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

【PM 验收 · ACCEPT】objectui 分片 PM(session_01GTRjn8xBqp75dk7kFupVRt)对 #4788 裁定(A/A/E,评论 5307576062)的实施验收:

实物核验:4 文件对账相符 —— form.tsx(+204/-12,机制段与裁定逐条对上:gate readonly && label && !groupLabelled && 注册解析、FormLabel {id, htmlFor: undefined} 去 for 发 id、容器 role="group" + 复合 aria-labelledby="labelId hostId" + describedby、aria-invalid 按 #4005 边界消费即弃、group-labelled 排除防嵌套)+ 两个新钉子(13 host 机制 + 116 真 widget e2e,全带容器归属断言)+ changeset。packages/fields/src/widgets/** 零触碰 —— E 方案「widget 一个文件都不改」的结构卖点逐字兑现。模型标识 0、releases 0。

反向验证:预判先写死(神经化 wrapper、只留 label-id 半),8+112 红、5+4 绿,实测 EXACT match 含逐测试名与第三条失败形态。probe 前后:34 个注册非 group 类型 + D 组四件全部 DANGLING/consumers=0SET/consumers=1,36 条 editable 行前后字节相同;email 复合名 "Label email user@example.com" 实测保值。CI 亲读:19 项全 completed(17 success + 2 skipped),零失败。

记账:「32 → 34」为枚举口径差(源文件 vs 注册类型),前提实质不变;image/signature 断言自纠(41c3add)是诚实读数;风险五条(DOM 层 data-slot 定位符、只读行间距对齐 editable 的视觉变更、复合名对大面统一施加、第三方 widget 假想缺口、group-labelled 排除)已在 changeset/PR 声明,均为裁定接受的代价。新 finding #4857(七 widget editable 态同族悬空 + D 组经 disabled:true 绕过本容器)未扩围、三方向留裁,记账合格,留分诊。

处置:undraft + auto-merge(SQUASH)。落 main 后 #4824 dev 的 form.tsx 前置门解锁。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 16, 2026 16:34
@yinlianghui
yinlianghui added this pull request to the merge queueAug 16, 2026
Merged via the queue into main with commit 7458a41Aug 16, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4788-readonly-host-wrapper branch August 16, 2026 16:34
yinlianghui added a commit that referenced this pull request Aug 16, 2026
…validation state and names its control (#4824, #4832)
The third and last of the three surfaces the 2026-08-16 ruling put in one
batch. Its gate cleared: #4788, which was rewriting this file's readonly path,
landed on main as PR #4862 and is merged into this branch, so this edit no
longer races it -- and the two edits touch different regions of form.tsx.
`error` is read off the PRE-strip props and forwarded by name, exactly the way
`label` and `mobile_fullscreen` already are: stripRendererOnlyProps discards it
for the DOM, correctly, because the INLINE control takes its aria-invalid from
FormControl's Slot. The dialog's control reaches no Slot -- it is built from
scratch here -- which is why it was announcing nothing at all while the inline
control announced true for the same field. The primitive decides what to do
with it; this branch only spreads the fourth children argument onto the control
it renders, so it cannot name the aria-hidden FormMessage outside the modal.
form-fullscreen-textarea-dialog-aria.test.tsx: 7 failed | 4 passed -> 11 passed.
Whole tree: 287 files / 3400 tests pass; type-check 12/12.
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 17, 2026
…bjectstack-ai#4832) (objectstack-ai#4864)
* fix(components,fields): the fullscreen long-text dialog announces validation state and names its control (objectstack-ai#4824, objectstack-ai#4832)
The dialog's editor is built from scratch by the host, so none of the wiring the
inline control gets from the form renderer reached it. Measured with the field
genuinely invalid: the rich-text dialog announced aria-invalid="false" while the
same field's inline control announced true, the textarea dialog carried no such
attribute at all, and every dialog control had an empty accessible name.
FullscreenEditor now takes the field's `error` (required) and owns the answer:
it renders the message in a dialog-local node and hands `children` a required
fourth argument carrying aria-labelledby (the dialog title's text), aria-invalid
and a single-IDREF aria-errormessage. The host spreads it and never learns an
id, so it cannot name a node outside the dialog -- the forbidden shortcut of
pointing at the host's FormMessage, which Radix aria-hidden's for as long as the
dialog is open.
TextAreaField and RichTextField are migrated here; the form renderer's built-in
textarea branch follows in this branch's next commit.
Co-authored-by: Claude <noreply@anthropic.com>
* test(components): pin the built-in textarea dialog's validation state and name (objectstack-ai#4824, objectstack-ai#4832)
The third of the three surfaces the maintainer's 2026-08-16 ruling put in one
batch. Its implementation is NOT in this commit: the form.tsx edit is gated
behind in-flight objectstack-ai#4788, which is rewriting the same file's readonly path, and
racing it on one file is what the gate exists to prevent.
So this file is RED on purpose, and it is the acceptance criterion for the
remaining work rather than a claim that the work is done. It is also the only
one of the three that can be measured against a REAL FormMessage -- this branch
renders inside FormControl with the form's own message node as a sibling -- so
it carries the assertion the other two cannot: the id the dialog control names
is not the form item's message id, and the node it resolves to is inside the
dialog.
Measured on this commit: 7 failed | 4 passed. The four that pass are the ones
that do not depend on the missing plumbing.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(components): the built-in textarea's fullscreen dialog announces validation state and names its control (objectstack-ai#4824, objectstack-ai#4832)
The third and last of the three surfaces the 2026-08-16 ruling put in one
batch. Its gate cleared: objectstack-ai#4788, which was rewriting this file's readonly path,
landed on main as PR objectstack-ai#4862 and is merged into this branch, so this edit no
longer races it -- and the two edits touch different regions of form.tsx.
`error` is read off the PRE-strip props and forwarded by name, exactly the way
`label` and `mobile_fullscreen` already are: stripRendererOnlyProps discards it
for the DOM, correctly, because the INLINE control takes its aria-invalid from
FormControl's Slot. The dialog's control reaches no Slot -- it is built from
scratch here -- which is why it was announcing nothing at all while the inline
control announced true for the same field. The primitive decides what to do
with it; this branch only spreads the fourth children argument onto the control
it renders, so it cannot name the aria-hidden FormMessage outside the modal.
form-fullscreen-textarea-dialog-aria.test.tsx: 7 failed | 4 passed -> 11 passed.
Whole tree: 287 files / 3400 tests pass; type-check 12/12.
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude