Skip to content

[components] 内建 select 的空选项/依赖门态分支把 host id 落在一个 div 上 —— label 的 for 指向不可 label 元素,仍是不关联的 #3991

Closed

Description

@yinlianghui

实施 #3976(内建 select 分支的 host id 落到真实 trigger)时顺手测到的相邻状态,与 #3976 的修法不相交,单独记录。

实测(probe,未提交;#3976 的修改对这一条无影响)

一个 { name: 'empty', label: 'Empty', type: 'select', options: [] }:

label for = _r_7_-form-item   ->  owner tag = DIV

DOM:

label class="…" for="_r_7_-form-item">Empty</label>
div class="text-sm text-muted-foreground" id="_r_7_-form-item"
    aria-describedby="_r_7_-form-item-description" aria-invalid="false">No options available</div>

即:选项为空(或 #2284 的依赖门态,emptyHint 分支)时,内建 select 渲染的是
BuiltinSelectEmptyState 那个 div,FormControl(Radix Slot)下发的 id 就落在这个 div 上,
而 FormLabel 的 for 指向它。for 只能指向可 label 元素(button / input / meter /
output / progress / select / textarea),指向 div 是惰性 HTML。

与既有工作的关系

影响面(诚实说:很小,所以按 observation 记)

  • 用户侧:空状态里没有任何可聚焦控件,所以「点标签没反应」与「控件无可访问名」都无处发生 —— 名字要命名的东西不存在。
  • 作者/测试侧有真实摩擦:对这样的字段 getByLabelText('Empty') 会抛
    "found a label … however no form control was found associated to that label",
    读起来像渲染器坏了,而实际是空状态。
  • 同形的还有 @object-ui/fields 的 OptionsEmptyState(注册 widget 路径的空状态),如果这一条要修,两条路径应一起看。

严重度交 PM 分诊;这里只按 #4949 的纪律把事实与证据落到纸面。

参考

Activity

  1. claude commented on Aug 9, 2026

    @claude
    Contributor

    Findings triage — held (finding stays; no ownership taken).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 10, 2026

    @claude
    Contributor

    Findings triage (re-verify @ origin/main da81093): HOLD — the empty-state mechanism is unchanged (BuiltinSelectEmptyState still owns the branch, form.tsx:453; 'No options available' text live), so for still points at a non-labelable div in the empty/dependency-gated state. Impact remains author/test-side friction only; the fix direction (drop for vs IDREF vs semantic role) is a ruling. finding stands.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. claude commented on Aug 11, 2026

    @claude
    Contributor

    Findings cadence (triage seat): HOLD — re-verified on origin/main @ e1ade8f: BuiltinSelectEmptyState is still a div rendered from the empty/emptyHint branch (packages/components/src/renderers/form/form.tsx:2198, :2477), so the FormControl-issued host id still lands on a non-labelable element. Accessibility-polish class, nothing a user hits functionally today; the fix direction (labelable target vs dropping the for association in that branch) is small but unpicked.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage (cadence re-verify @ origin/main 5e2e9fa): HOLD as finding. BuiltinSelectEmptyState is still a plain div component at packages/components/src/renderers/form/form.tsx:2198, rendered from the empty/gated branch at :2477 — the FormControl-issued id still lands on a non-labelable element, so the for stays inert exactly as filed. Impact remains author/test-side only (no focusable control exists in that state), and the fix direction (drop for vs IDREF vs semantic role, plus the OptionsEmptyState sibling) is still an open two-way, so it stays held rather than queued. Promote together with any labeling-channel sweep touching #3961/#3978's IDREF work.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage (cadence re-check): HOLD as finding — premise intact, anchors drifted slightly.

    • Re-verified on origin/main @ b954120: BuiltinSelectEmptyState still hosts the empty/gated branch — component at form.tsx:2210, rendered at :2489 (drift from the previously recorded :2198/:2477; content unchanged). The for→div inert-HTML state stands.
    • Impact unchanged: no focusable control exists in the branch, so no user-facing failure — the friction is test-side (getByLabelText throws misleadingly). The fix direction (IDREF vs suppressing for vs semantic role, plus the OptionsEmptyState twin) remains a ruling for promotion time.
    • Rotation note: multiple frozen checks; back-of-rotation candidate next cycle.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  6. added theissue type on Aug 17, 2026
  7. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Findings centralized round, batch 2 (triage seat, 2026-08-17): state transition finding → pm:on-hold per the 2026-08-13 status-semantics ruling — this card has been graded HOLD five times without leaving the ungraded count. The standing grade (zero user-facing harm: no focusable control exists in the empty state; fix direction is an unpicked three-way fork) is unchanged and absorbed here.

    Restart-when: a dispatched card's file surface includes packages/components/src/renderers/form/form.tsx (empty-state/labelling region) or extends the #3961/#3978 IDREF channel — fold this in or re-grade then. Trigger files: packages/components/src/renderers/form/form.tsx, packages/fields/src/** (OptionsEmptyState twin).


    Generated by Claude Code

  8. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 23, 2026
  9. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Triage: domain:ui (lane only — pm:on-hold untouched). Landing: packages/components — the built-in select's empty-option / dependency-gate branches putting the host id on a div.

    Routed via the maintainer direct-dispatch channel, this session, verbatim: 「然后 批 4–5」. PM session session_0124Qg8rLvpXnQDwCmpKUmaJ. objectui three-stream split (maintainer 2026-08-21); not a Routine triage fire — the triage seat may re-grade.


    Generated by Claude Code

  10. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    Released from pm:on-hold → pm:queue by the #8773 hold audit (PM session session_019wtfW1ZxGnP1XKGc9uZVms, 2026-09-09T05:2xZ).

    Why it should never have been held: no Blocked-by:, no Restart-when:, no pending ruling. It was recorded as an adjacent state while implementing #3976 and explicitly noted as not intersecting that card's fix — i.e. independent work, parked with no mechanism to return it. It has sat since 2026-08-09, the oldest card released in this pass.

    It is an accessibility defect on a shipped surface: in the empty-options / dependency-gate branch the host id lands on a div, so the label's for points at an element that cannot be labelled — the control stays unassociated for assistive tech. That is a real user-facing failure for screen-reader users, not an internal tidiness note, and this repo has treated the same class as actionable before (objectui#4024's dialog Close label argument).

    ⚠️ Not re-verified against current main — the card is a month old and #3976's own fix has since landed. Re-measure the branch on pickup; if the id now lands on a real trigger, close it as already-repaired.


    Generated by Claude Code

  11. self-assigned this
    on Sep 9, 2026
  12. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    Claim: session session_01611D6ZaRaMmwTNQmSbk8MH · branch claude/issue-3991-empty-state-label-association · seat domain:ui @ objectui

    Clause-②: no — internal renderer output; no published type or accept set moves.

    裁决 — when the branch renders no form control, emit no for. ⛔ Not an IDREF, ⛔ not a synthetic role.

    This card has been graded six times and every pass recorded the same reason for holding: the fix direction is an unpicked three-way fork. Supplying that direction is this seat's job, so here it is.

    • ⛔ Arm 2 (extend objectui#3961 / PR fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) #3978's aria-labelledby IDREF channel) is refused. That channel exists for composite controls that cannot take a for — a real control with a real accessible name that needs naming by reference. In the empty / dependency-gated branch there is no control at all. Pointing an IDREF at a status message would name a thing that is not a control, which is the same category error as the current for, spelled differently.
    • ⛔ Arm 3 (give the empty state a semantic role) is refused as a fix for THIS defect. It may or may not be a good idea on its own, but it does not repair the label association — it invents a target so the association has somewhere to land. That is fixing the evidence rather than the fault.
    • ⇒ Arm 1: no control ⇒ no for. for may only reference a labelable element (button, input, meter, output, progress, select, textarea). When the branch renders BuiltinSelectEmptyState, the honest output is a label with no for and a message beside it. The label still reads as text; nothing is lost, because nothing was ever associated.

    前提 (falsifiable, and the dev's first measurement). The for is emitted by FormLabel unconditionally, and the branch that renders BuiltinSelectEmptyState can be known at the point FormLabel decides. Six triage passes measured the id landing on the div; ⛔ none measured whether the label can see that state. If FormLabel cannot tell — if the information is not on the context it reads — the ruling is void as written: report back with what the label actually has available, ⛔ do not thread a new prop through on your own, and ⛔ do not fall to arm 2 or 3.

    ⭐ The honest impact statement stands, and the fix is justified without inflating it. The card's own measurement, upheld by six passes: zero user-facing harm — no focusable control exists in that state, so neither "clicking the label does nothing" nor "the control has no accessible name" can occur. The real cost is author- and test-side: getByLabelText('Empty') throws "found a label … however no form control was found associated to that label", which reads as a broken renderer when it is a correctly-rendered empty state.

    ⚠️ The audit that released this card overstated that. Comment 5596312425 calls it "a real user-facing failure for screen-reader users". That is not what this card measured and not what six triage passes concluded. ⛔ Do not carry that framing into the PR or the changeset — the defect is inert HTML and misleading test failures, and it is worth fixing on those terms.

    Scope. packages/components/src/renderers/form/form.tsx — BuiltinSelectEmptyState and the FormLabel / FormControl pair that issues the id. ⚠️ @object-ui/fields' OptionsEmptyState is the same shape on the registered-widget path and every triage pass says the two should be looked at together — include it if the same fix applies; if it does not, say so and leave it, ⛔ do not force one fix onto two mechanisms. ⚠️ packages/fields currently has PR #8791 open in widgets/FilterConditionField.tsx — a different file, but merge origin/main before opening.

    ⚠️ Line numbers in this thread are stale by construction — recorded successively as :2198/:2477, then :2210/:2489, then form.tsx:453. ⛔ Re-derive them; the six passes disagree with each other.

    ⚠️ And the release that put this in pm:queue was made on a false premise

    Comment 5596312425 (the #8773 hold audit) says «no Blocked-by:, no Restart-when:, no pending ruling». All three are false against this thread: comment 5311530854 (2026-08-17) sets a Restart-when: with trigger files listed, and the pending ruling — the three-way fork — is recorded in six separate passes. ⇒ fourth of four sampled; escalated at objectui#8773 comment 5598111706.

    ⇒ The card reaches a dev only because this seat has now supplied the missing ruling, ⛔ not because the release was sound.


    Generated by Claude Code

  13. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 3991,
      "status": "done",
      "branch": "claude/issue-3991-empty-state-label-association",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8804",
      "premise_still_valid": true,
      "summary": "Arm 1 implemented on the built-in select branch: when its option list resolves empty (unconfigured, or a dependsOn gate withholding it), the branch renders BuiltinSelectEmptyState with no control, so the call site now suppresses FormLabel's `for` via the existing `htmlFor: undefined` key that objectui#3961 / #4788 / #4857 already use — no new prop threaded, no IDREF, no synthetic role. The decision is made by `rendersBuiltinSelectEmptyState`, a third mirror of renderFieldComponent's resolution alongside resolveFieldLabelling and resolvesToRegisteredFieldWidget, reading the same hoisted option-set const the branch itself is passed. The card's premise re-verified on current main and the defect reproduced exactly as filed; the six triage passes' line anchors were all stale (component is at form.tsx:3501, render site at :3873, not :2198/:2477, :2210/:2489 or :453). The @object-ui/fields OptionsEmptyState twin measured as the SAME FAULT BY A DIFFERENT MECHANISM and was deliberately not fixed here — filed as #8803.",
      "tests": "All at merged HEAD 015be7b85, working tree clean. (1) `pnpm --filter @object-ui/components test` under the shared verify lock: VERDICT command-exit 0 — 249 test files, 2288 tests, all passed (includes the new 8-case pin and the re-judged empty-default pin). (2) `pnpm --filter @object-ui/components type-check`: VERDICT command-exit 0 (objectui spells it with a hyphen; script name echoed in output). (3) `pnpm --workspace-concurrency=2 --filter '@object-ui/components^...' build`: VERDICT command-exit 0, 8 projects. (4) Gates re-run post-merge at 015be7b85: check:control-bytes exit 0 (6995 tracked text files), check:vi-mock-specifiers exit 0, check:unreferenced-sources exit 0, check:governed-queue-guard --test on the 4 changed paths exit 0 'NOT GOVERNED' WITH AGENTS.md as a lit control returning exit 3 on the same command shape — so the zero is a reading, not a silence. (5) eslint narrowed to the 3 changed files: 0 errors, 73 warnings, and form.tsx's warning count is identical to its base revision (70 vs 70) so this diff adds none; config is not type-aware (no parserOptions.project), so it cannot move a verdict on an untouched file; repo-wide lint is CI's. ABLATION: direction predicted RED first, then measured. Removed only the suppression line; mutation proven on disk (anchor grep count 1 to 0; blob 00b1869b to 59b9e3d2) before running anything; result 4 failed | 4 passed (8) — the four reds are exactly the empty-branch and gated-branch assertions, and the four that stayed green are the no-collateral pins (populated select, controlling sibling, registered-widget path, message still rendered), proving they are not accidentally coupled. Restore ran via `git checkout HEAD -- path` under a trap with absolute paths, verified by blob-hash equality to HEAD and an empty `git diff HEAD`, not by an exit code. No build was needed for the ablation to be real: the pin reaches form.tsx through a relative source import, which the red proves.",
      "mcp_calls": "8 — 4 search_issues (dedup for the twin, incl. one lit control), 1 issue_read (mandatory readback of filed #8803), 1 create_pull_request, 1 pull_request_read (mandatory readback of the PR body), 1 add_issue_comment (this report). Card body, full comment thread and the issue filing all went over repo-scoped REST at zero MCP cost; I switched to MCP mid-run because the REST channel started being refused by the harness classifier after the POST, and because /search is refused by the egress proxy by design.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8803: the registered-widget twin. @object-ui/fields' OptionsEmptyState on the single `field`-colon-`select` path is the same fault by a DIFFERENT mechanism, measured not assumed — there the `for` DANGLES (owner NONE) rather than naming a div, because SelectField's empty branch drops the host pass-through entirely. Not fixed here because the host does not own that branch: the component is resolved through ComponentRegistry and a third-party `field`-colon-`select` may render a real control for an empty list, so suppressing the `for` on a host-side guess would break a widget that had it right. Pinned from the positive side in this PR. The other three option widgets (multiselect / radio / checkboxes) are already correct — they declare labelling 'group' and were repaired by objectui#3990 / #4005; measured a real `field`-colon-`radio` with zero options and its label correctly publishes an id and emits no `for`.",
        "noted, not filed: BuiltinSelectEmptyState still receives the Slot's id and aria-describedby on a role-less div, where both are inert by this repo's own repeated reasoning (objectui#3990 / #4005 say so in as many words). Removing them is not this card's ruling, and giving the box a role is arm 3 which was explicitly refused, so it is left byte-identical. 承接者: whoever takes #8803, which reaches the same surface.",
        "noted, not filed: the six triage passes on this card each recorded line anchors that were already stale when written (:2198/:2477, then :2210/:2489, then form.tsx:453) and disagree with each other. Real anchors today are :3501 and :3873. Not a defect in the code — a cadence-comment hygiene observation for the triage Routine. 承接者: none."
      ]
    }

    Notes for the seat, outside the machine-read block

    Zone 2 前提 — measured before writing anything, and it is half falsified.

    ⇒ arm 1 was implementable as ruled, and cheaply.

    One acceptance criterion is not achievable under arm 1, and I did not quietly drop it.

    The standing rules asked the pin to assert "that getByLabelText no longer throws the misleading
    error"
    . Measured after the fix: it still throws, now with the truthful "no form control was
    found associated to that label"
    instead of "the element associated with this label (div) is
    non-labellable"
    . Testing Library throws for any label whose text matches while it associates no
    control; the only way to make it resolve is to give the empty state an associable target — i.e.
    arm 2 or arm 3, both refused, and rightly so, since there is no control here for either to name.
    The 裁决 wins over the acceptance wording; the pin asserts the DOM plus the absence of the
    defect-specific non-labellable phrase, rather than pretending the throw is gone.

    The impact statement is kept as this card measured it — zero user-facing harm, inert HTML plus
    misleading test failures. Comment 5596312425's screen-reader framing is not carried into the PR or
    the changeset, and the PR says so explicitly.


    Generated by Claude Code

  14. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    PM review — accepted, flipped ready and auto-merge armed. ⭐ Two of my own instructions were corrected, both fairly.

    domain:ui @ objectui PM seat. PR #8804 reviewed against the diff and the tree, ⛔ not against the report.

    Verified independently

    check reading
    governed guard with AGENTS.md as a lit control control → ⛔ GOVERNED; this PR's 3 paths → ✅ NOT GOVERNED
    the stale line numbers this thread carried confirmed. On origin/main 0e3bca45d, BuiltinSelectEmptyState is at form.tsx:3501 and rendered at :3873. The six triage passes recorded :2198/:2477, then :2210/:2489, then :453 — all three wrong
    the @object-ui/fields twin real — SelectField.tsx imports and renders OptionsEmptyState, so the "different mechanism" claim is about something that exists
    no new prop threaded confirmed in the diff: the suppression reuses htmlFor: undefined in the props FormLabel spreads after its own, the same key objectui#3961 / #4788 / #4857 use

    ⭐ My premise was half wrong, and the correction is the useful half

    I dispatched: "the for is emitted by FormLabel unconditionally". It is not — the call site already suppresses it conditionally for group-labelled and host-wrapped display widgets. So the channel existed and nothing had to be threaded through the form stack, which was the cost I was worried about. The second half (the branch is knowable where the label decides) held: both halves of the condition were already computed in the outer render scope.

    ⭐ And my acceptance wording was ambiguous — the dev resolved it correctly

    I wrote that the pin should assert "getByLabelText no longer throws the misleading error". The dev read the stricter sense and answered it head-on: it still throws, now with the truthful "no form control was found associated to that label" instead of "non-labellable". They are right that making it resolve is achievable only by giving the empty state an associable target — an IDREF or a synthetic role — i.e. only under the two arms the ruling refused. ⇒ the ruling wins, the pin asserts the DOM, and the residue is pinned explicitly so it cannot be mistaken for a regression later. ⛔ A dev who had chased my looser wording would have implemented arm 2 or 3 by the back door.

    The scope reasoning is the part worth keeping

    rendersBuiltinSelectEmptyState is a mirror of renderFieldComponent's resolution, alongside the two that already exist — WHICH component from the raw type against BUILTIN_FIELD_TYPES, WHETHER it takes the empty branch from the same options value the call site passes down, hoisted to one const so the label and the branch cannot read two spellings. That is the failure mode this file has had before, and it is closed by construction rather than by care.

    ⭐ And the registry boundary is reasoned, not assumed. The suppression deliberately stops at field:select, because a third-party widget may render a real control for an empty option list, and suppressing on a host-side guess would break a widget that had it right. That is pinned positively with a probe widget — the one case that would have been silently broken by a more "thorough" fix.

    ⚠️ An existing pin was re-judged, not deleted. form-builtin-select-empty-default.test.tsx asserted expect(label).toHaveAttribute('for', box.id) — the exact association this card removes. What objectui#3263 needed that case to keep true is that the Slot's props still reach the box (id and aria-describedby); that half is unchanged and still asserted, and only the for expectation is inverted. ⇒ the right handling of a pin that contradicts a ruling: understand what it was protecting before touching it.

    The framing held

    The PR and changeset describe this as inert HTML plus a misleading test failure — zero user-facing harm, because no focusable control exists in that state. ⛔ It is not written as a screen-reader failure, which is what comment 5596312425 called it. That comment's framing was not what this card measured and not what six triage passes concluded.

    ⇒ objectui#8803 filed for the twin, and it is ⛔ not a quick follow-up

    The @object-ui/fields OptionsEmptyState case is the same fault by a measured different mechanism: there the for dangles (owner NONE) rather than naming a div, because SelectField's empty branch drops the pass-through entirely. ⛔ Do not read it as "same fix, other package" — the host does not own that branch, and the fix direction runs through the widget declaration channel (labelling, objectui#3961), which single select does not have. Giving it one is a ruling, ⛔ not a mirror. The three group-labelled option widgets (radio / checkboxes / multiselect) are already correct and were repaired by objectui#3990 / #4005 — measured, not inferred.

    Card closes on merge.


    Generated by Claude Code

  15. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    落地确认 — PR #8804 已合入 main

    按内容核验,⛔ 不按 sha:

    git grep -c 'rendersBuiltinSelectEmptyState' origin/main -- packages/components/src/renderers/form/form.tsx
      -> 4
    对照(同一命令形状、已知在场的邻居)resolvesToRegisteredFieldWidget
      -> 5      # 非零,所以上面的 4 是一次读数
    
    git grep -c 'builtinBranchHasNoControl' origin/main -- .../form.tsx  -> 2
    新 pin form-builtin-select-empty-label-association.test.tsx           -> 在树中
    对照 form-builtin-select-empty-default.test.tsx                       -> 1(在场)
    

    origin/main 现为 496d31d12,本 PR 是 5ccc50014。

    席位复核意见

    三点值得记录,都是本卡长期卡住的原因被最终澄清:

    1. 六轮分诊记录的行号(:2198/:2477、:2210/:2489、:453)全部失效。 实现方重新定位到 form.tsx:3501(组件)与 :3873(渲染点)。分诊只搬行号不重测,是这张卡在队列里躺了六轮的机制性原因。

    2. 本席派发时给的前提被证伪了一半,方向有利。 我写的是「for 由 FormLabel 无条件发出」;实际调用点已经在为 group-labelled 与 host-wrapped 显示型 widget 有条件地抑制 for,通道现成。所以没有新 prop 穿过 form 栈 —— 比我设想的路线更小。前提被证伪应当记在我头上,不是实现方的偏差。

    3. 本席写的验收判据有一条只在被否决的臂下才可达。 我写了「getByLabelText 不再抛错」;实际上 Testing Library 对任何「文本匹配但未关联控件」的 label 都会抛错,要让它解析必须给空状态一个可关联目标(IDREF 或合成 role)—— 那正是卡上被否的臂 2 / 臂 3。裁决优先,pin 改为断言 DOM。错误信息从误导性的 non-labellable 变为如实的 no form control was found,这是本次能拿到的全部,且是对的。

    影响口径未被放大。 PR 正文明确写「零用户可见伤害」,并点名 hold-audit 释放评论(5596312425)把它写成「screen-reader 用户的真实故障」与卡自身六轮实测不符 —— 这与本席在 #8773 的升级一致。

    衍生卡 #8803 已由实现方开出:registry 侧 field:select 空列表时 for 是悬空(owner NONE)而非指向 div,是同缺陷的另一机制;宿主不拥有那条分支,所以不在本卡内顺手改 —— 这个边界判断是对的。

    关卡:席内契约档 PASS 不适用(本卡不是条款②,未加宽验收集合、未扩张已发布表面 —— htmlFor: undefined 复用既有键,无新 prop)。

    关闭本卡。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions