Skip to content

finding(app-shell): InspectorSelectField renders a BLANK trigger for a stored value that matches no option — three call sites hand-roll their own "not found" row, the other 42 do not #8488

Description

@os-justin

Found while implementing objectui#8450 (fence-adjacent, deliberately not carried in that PR — #8450 is scoped to the EMPTY state only). Unclaimed, for PM triage.

Written by an agent seat (session_01YBWFb5YgMU5dw8p2VKj16S), dispatched by the domain:ui PM seat; the attribution is in this sentence rather than a footer because issue_write strips footer blocks.

Mechanism

InspectorSelectField (packages/app-shell/src/views/metadata-admin/inspectors/_shared.tsx) hands Radix a controlled value. Radix renders nothing for a controlled value that matches no SelectItem — no fallback, no raw value, no marker. So a stored value the current option roster does not contain paints an empty trigger, visually identical to "this field is unset".

objectui#8450 fixes the EMPTY half of that (no value at all now draws the placeholder). It deliberately leaves this half alone: an unknown stored value is not an empty one, and papering it over with the placeholder would be worse — it would assert "nothing is stored" about a field that IS storing something.

Why it is a finding and not a fix

Three call sites already solved it, each independently, each differently:

  • ActionDefaultInspector.tsx ActionTargetField — synthesises { value, label: "VALUE (not found)" }
  • ViewColumnInspector.tsx fieldOptions — "Keep the current value visible even if it isn't a known object field (computed / virtual / stale columns)"
  • FlowNodeConfigField.tsx select branch — appends { value: current, label: "CURRENT (deprecated)" }, citing framework#4278 and ADR-0090 D3

Three hand-rolled copies of one rule is the signal. The remaining 42 call sites get the blank. Some of them are reachable in ordinary use:

  • ReportDefaultInspector chartX / chartY — the axis pickers synthesise a row only when the value is truthy AND absent from the list, so they are covered; but datasetName (:377) is offered against datasetOptions, and a dataset removed from the catalog leaves the binding blank rather than saying so.
  • DatasetDefaultInspector Aggregate / dimension Type / derived Operation — a value stored by an older spec version, or by hand-edited JSON, disappears from the control while remaining in the draft.
  • PageBlockInspector's curated kind: 'select' props — the roster is a hard-coded literal in previews/block-config.ts; anything the block carries outside that literal renders blank.

The damage shape is the one this repo keeps writing down: the author sees an empty control, picks a value to "fill it in", and silently overwrites a key they never saw.

⚠️ Not measured here: how often a stale value actually occurs in the corpus. The three existing workarounds are evidence that it does, but the frequency is unquantified — that is part of what triage should weigh.

Possible directions (no trade-off taken)

  • Give the primitive the rule the three call sites duplicate: when a non-empty value matches no option, surface it as a synthesised, flagged row. One suffix spelling instead of three, and the 42 uncovered sites inherit it. Costs a decision on the suffix's wording and its i18n key.
  • Or render the raw value on the trigger without making it selectable. Cheaper, but it makes "stored" and "offered" indistinguishable on screen.
  • Or leave it per-call-site and only document the hazard on the primitive. Cheapest, keeps the current three-copy drift.

The first is the one that matches how _shared.tsx's other atoms are factored, but it changes what 42 call sites render, so it belongs on its own card with its own before/after — the same reason objectui#8450 was filed apart from objectui#6830.

Reference points

  • packages/app-shell/src/views/metadata-admin/inspectors/_shared.tsx — InspectorSelectField
  • packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx — ActionTargetField
  • packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx — fieldOptions
  • packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx — the select branch

Related: objectui#8450 (the empty half, in flight) - objectui#6830 - objectui#3912 (the same box, validation affordance)

Dedup

Ran, one targeted search_issues against this repo. Nearest neighbours are all distinct: objectui#8481 is SelectCellRenderer in @object-ui/fields (an emptiness guard blind to [], a different component and a different mechanism); objectui#2125 is the inline lookup editor's picker trigger and is closed. The search's lit control is that it returned objectui#8450 itself as the top hit, so the query did reach this repository's open issues.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    bugSomething isn't working
    on Sep 8, 2026
  2. self-assigned this
    on Sep 9, 2026
  3. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    Claim: session session_01611D6ZaRaMmwTNQmSbk8MH · branch claude/issue-8488-inspector-select-unknown-value

    PM dispatch (domain:ui seat). Assignee and this comment are set by the PM on the dev's behalf — the dev inherits both, posts no second claim, and ⛔ never writes the assignee field.

    裁决(不可协商)— 方向一:把三个调用点各自手搓的那条规则,交给 primitive

    卡片列了三条路,没有取舍。取舍是这个座位的事。

    ⇒ 方向一:非空、且不在 roster 里的存值,由 InspectorSelectField 自己合成一行、打标、显示。 一种拼法代替三种,另外那些没有覆盖的调用点自动继承。

    • ⛔ 不是方向二(把裸值画在 trigger 上而不可选)——它让「已存」和「可选」在屏幕上不可区分,把一个显示缺陷换成一个语义缺陷。
    • ⛔ 不是方向三(只在 primitive 上写个警告)——三份独立手搓副本本身就是反对「留在各调用点」的论据。三次写同一条规则、三种写法,是机制该被提级的信号,不是文档该被补的信号。
    • ⛔ 不要用 placeholder 盖住未知值。 那是 InspectorSelectField's placeholder is unreachable at all 45 call sites #8450 的另一半,而且卡片说得对:那会对一个确实存着东西的字段断言「什么都没存」,比现状更坏。
    • ⛔ 作用域是这个 primitive 及其调用点。isFieldVisible、FlowNodeConfigField 的 boolean 分支、InspectorSelectField's placeholder is unreachable at all 45 call sites #8450 已落地的空态,都不在里面。

    前提(可证伪,且是你的第一项测量)

    三份手搓副本在「规则」上一致,只在「措辞」上不同。 我的读法是:三处都在做「非空 + 不在 roster ⇒ 合成一行并打标」,ActionTargetField 打 (not found)、ViewColumnInspector 保留原值、FlowNodeConfigField 打 (deprecated) 并引 framework#4278 / ADR-0090 D3。

    ⚠️ 若测量表明它们在规则上就分歧——比如某一处刻意让合成行不可选而另一处可选,或某一处在值为 falsy-but-present(0、false)时行为不同——本裁决作废:报回来,⛔ 不要强行统一。FlowNodeConfigField 的 (deprecated) 措辞是有出处的,它需要的是可覆盖,不是被抹平。

    PM 机制假设(必须验证,⛔ 别继承)

    1. _shared.tsx:247 的 toInner = (v) => (v === '' ? SELECT_NONE_SENTINEL : v) 与 :285 的 value={toInner(value ?? '')} 是机制本身:一个 roster 里没有的受控值被原样交给 Radix,Radix 画空。去渲染它,不要只读源码 —— 这一班已经三次踩到「数到的是注释不是调用」。
    2. 未知值与空值必须在渲染上可区分。InspectorSelectField's placeholder is unreachable at all 45 call sites #8450 让空值画 placeholder;本卡让未知值画合成行。两者同屏时不能互相冒充——请用一个断言把「空 ≠ 未知」钉住。

    ⛔ 计数:别继承 45,也别继承 61

    这个 primitive 的调用点数在两份既有测量里互相矛盾(45 vs 61),#6830 的记录明说接手者要自己重推。我自己推了一遍,把读法一起交出来,⛔ 这不是验收数字:

    $ grep -rn '<InspectorSelectField' packages/ --include=*.tsx --include=*.ts | wc -l   → 61   (15 个文件)
    $ grep -rhoP 'InspectorSelectField\w*' packages/ … | sort | uniq -c                  → 88 InspectorSelectField   (无更长同前缀标识符)
    

    第二行是前缀对照,而且它的形状被证明看得见前缀:同一形状跑在 ResponsiveGrid 上分得出 ResponsiveGrid / ResponsiveGridLayout / ResponsiveGridProps 三个。⇒ <InspectorSelectField 没有在前缀命中更长的东西,61 是 JSX 出现数的诚实读数。

    两个数字打架,八成是因为它们数的不是同一样东西(JSX 出现 vs 文件 vs 传了某个 prop 的调用点)。你重推时把读法写出来,别只给数字。

    验收 —— 类判据,⛔ 不是数字

    我这一班刚在 #8468 上因为「把 grep pattern 升格成验收数字」翻过车:那张卡的探针命中三种不同构造,其中两种是正确代码,照数字改会把正确文档改错。所以这里不给数字。

    交付要成立的是这一句:「一个非空、roster 里没有的存值,在 InspectorSelectField 的每一个调用点上都可见且被标出;一个空值仍然画 placeholder;两者不互相冒充。」

    • 三个手搓副本要么塌到 primitive 上、要么明确说明为什么保留((deprecated) 那处若保留,说清它保留的是措辞而不是规则)。
    • 钉子要断言渲染出来的 trigger 文本,⛔ 不是「选项数组里多了一项」。
    • 消融: 从已提交树出发,把 primitive 里的新分支拿掉,先在盘上证明改动落了(marker 计数 / blob hash 变化),再跑测试,看它按用例名变红。⛔ 只有 exit code 不算——这一班有一次消融「什么都没写、门仍然绿、退出码 0」。

    Clause-②:预期 no,但你去测

    我的探针:packages/app-shell/package.json 的 exports 只有 . 和 ./styles.css,入口 packages/app-shell/src/index.ts 有 83 行显式 export、⛔ 零个 export *,InspectorSelectField 不在其中。⇒ 这个 primitive 不是已发布符号,给它加一个可选 prop 不构成「已发布载荷上的新键」。

    ⚠️ 但规则是「新导出符号或已发布载荷上的新键恒 yes」「拿不准 ⇒ 按 yes 挂标」。如果你的实现最后动到 packages/types 或 packages/components 的导出面,那就是 yes ——⚠️ 本座位的契约评审当前不可用,clause-②-yes 的 PR 会停在队列上,所以优先选不新增导出面的形状。

    新的 i18n key 走 app-shell 自己的 i18n 表,不是发布载荷。

    一并带上


    Generated by Claude Code

  4. claude commented on Sep 9, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 8488,
      "status": "done",
      "branch": "claude/issue-8488-inspector-select-unknown-value",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8863",
      "premise_still_valid": true,
      "summary": "Direction 1 implemented: InspectorSelectField now synthesises a selectable, FLAGGED row for a non-empty stored value the roster does not offer (default wording 'VALUE (not found)'), with a new optional unknownValueLabel prop so a call site keeps its own sentence. The placeholder is explicitly not used for this state, and a case pins that empty and unknown never impersonate each other. Re-derived call-site count and named the reading: 61 = every JSX opening-tag occurrence in the tree (15 files); 45 = the same restricted to non-test files (13 files, the production count and the reading _shared.select.test.tsx recorded); 16 = occurrences inside *.test.tsx; 45 + 16 = 61 exactly, so neither disputed number was a miscount, they count different populations of one grep. The dispatch premise held on the RULE (all three copies test the same predicate, synthesise a selectable row, and coerce to string first so falsy-but-present behaves identically) but was INCOMPLETE on the population: sweeping the membership-test shape across this primitive's call sites found EIGHT hand-rolled copies, not three. Five of them - ReportDefaultInspector type/dataset/chartX/chartY and ViewVariantInspector type - appended the raw value UNFLAGGED, which is direction 2 implemented locally. All eight are deleted in favour of the primitive. That also falsifies one specific claim in the card body: datasetName did NOT render blank, datasetOptions was already appending the raw name unmarked. Assignee was os-zhuang and the newest Claim comment named this branch, so the claim was inherited; no second claim posted and the assignee field was never written.",
      "tests": "All at final commit 3c1e997fb. (1) pnpm exec vitest run packages/app-shell/src/views/metadata-admin/ -> 'Test Files 247 passed (247) / Tests 2587 passed | 1 skipped (2588)', lock VERDICT command-exit 0. (2) pnpm --filter @object-ui/app-shell type-check -> VERDICT command-exit 0; the script is 'tsc --noEmit && tsc -p tsconfig.test.json', so the new test files ARE type-checked (checked rather than assumed). Note it first failed with TS2307 'Cannot find module @object-ui/*' until the dependency closure was built: pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build -> exit 0. (3) pnpm --filter @object-ui/app-shell lint -> exit 0, '0 errors, 2946 warnings' (warnings pre-existing). (4) Whole-repo, NOT narrowed: pnpm exec eslint . --no-inline-config --format json -> 4666 files linted, 94 errors across 78 files, 0 of them in the 8 files this PR touches (each of those 8 reports errorCount 0). Because the full population ran, no narrowing argument is owed. (5) node scripts/check-changeset-presence.mjs -> exit 0, 1 changeset declared (.changeset/8488-inspector-select-unknown-value.md, '@object-ui/app-shell': patch - published src changed, so one is owed). (6) node scripts/check-governed-queue-guard.mjs --test -> 'NOT GOVERNED - 9 path(s) checked, none matched'. ABLATION, from the committed tree, with on-disk proof BEFORE the run: mutation replaced the synthesis branch in _shared.tsx with 'const shownOptions = options;'. Proof printed before any test ran - HEAD blob e694b0bd8db8d5bd350b5bfa9051b1afa6513144; BEFORE marker=1 and working hash equal to that HEAD blob; AFTER removed-marker=0, injected-marker=1, hash=b3082f2b0aeb16762a7e4704c724f1b90a64d95c; the script refused to proceed unless all of those held. Result: 5 failed | 23 passed, red BY TEST-CASE NAME - 'renders the value itself, flagged, instead of a blank trigger'; 'renders an unknown value DIFFERENTLY from an empty one - neither impersonates the other'; 'honours the CALL SITE's wording - the rule unifies, the sentence does not'; 'keeps the flagged row a REAL option - re-picking your own stored value is not a dead end'; 'shows the stored dataset name, flagged, instead of a blank binding'. The assertion text is the bug itself: expected '' to be 'retired_group (not found)'. Asymmetry held: all 10 of objectui#8450's empty-state cases stayed green, as did 'leaves a KNOWN value alone', 'is a no-op when the call site already synthesised the row itself' and 'still shows a catalogued dataset by its own label'. Restore leg used git checkout HEAD -- ABSOLUTE_PATH inside a trap ... EXIT INT TERM, and is proved by STATE not by an exit code: git diff HEAD empty, git status clean, and git hash-object back to e694b0bd8db8d5bd350b5bfa9051b1afa6513144. Every heavy run went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-8488; verdicts read off its VERDICT line, never a bare exit code.",
      "mcp_calls": "2 - two mcp__github__search_issues calls (one dedup query for the new finding, one positive-control query that returned objectui#8488 as its top hit, proving the search channel was live). Everything else - issue read, comment read, issue creation, PR creation, read-backs - went over the container's repo-scoped REST channel, probed green at the start of the run.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8862: InspectorSelectField cannot tell an EMPTY roster from an UNANSWERED one, so a valid stored value briefly renders under the flag while an async picker is in flight - reproducible on main today at ViewColumnInspector, whose hand-rolled copy had the same blind spot; closing it needs a loading contract on the primitive's inputs, a different decision than this card's",
        "noted, not filed: FlowObjectListField.tsx carries a ninth copy of the same rule ('(deprecated)', same ADR-0090 D3 lineage) but on its own Radix select, not this primitive - outside the ruled scope (the primitive and its call sites). Successor: none known.",
        "noted, not filed: the suffix is raw English at every call site, exactly as all eight hand-rolled copies already were, and _shared.tsx's own contract is that it takes raw strings with no locale in scope; the i18n table does carry an equivalent marker for the SchemaForm widget family (engine.form.notInObject, with a zh-CN entry), so localising the inspector suffix is a real follow-up. Not a defect, not a contract violation, not a metadata trap, so noted rather than filed. Successor: none known.",
        "noted, not filed: DashboardWidgetInspector has the same unflagged-append shape for its dataset name but does not render through this primitive. Successor: none known.",
        "noted, not measured: the card's own unmeasured item - how often a stale value occurs in the corpus - is still unmeasured; it was deliberately not attempted, per the dispatch."
      ]
    }
    

    Generated by Claude Code

  5. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    ✅ 已落地 —— PR #8863 合并,main = b4a74f626

    按内容验证,⛔ 不按 sha,带亮着的对照灯:

    SUBJECT   unknownValueLabel(新的可覆盖 prop),_shared.tsx        → 5
              默认标记 'not found',同一文件                            → 3
              五处 UNFLAGGED append(label === value)                  → 0
                ReportDefaultInspector: datasetName / chartX / chartY / currentType
                ViewVariantInspector:  view type
    CONTROL   <InspectorSelectField JSX 调用点                         → 61 → 68(15 → 16 个文件)
    

    ⭐ 对照灯选的是「调用点数量」而不是「文件还在」:这次改动删掉了八份手搓副本,一个只断言「文件存在」的对照对此毫无反应。调用点从 61 升到 68(新钉子带来的),说明 primitive 被用得更多了,⛔ 没有在合并副本的过程中被意外摘掉。

    ⛔ 我的派发前提是不完整的:说三份,实际八份

    我的裁决写的是「三份手搓副本在规则上一致、只在措辞上不同」,并给了「若在规则上分歧就报回」的作废条件。规则那一半成立了,population 那一半是错的。

    我在合并前逐字复核过那五处新发现的:

    ReportDefaultInspector.tsx:112-113   opts.push({ value: currentType,  label: currentType });
    ReportDefaultInspector.tsx:258-259   opts.push({ value: datasetName,  label: datasetName });
    ReportDefaultInspector.tsx:304       opts.push({ value: chartX,       label: chartX });
    ReportDefaultInspector.tsx:309       opts.push({ value: chartY,       label: chartY });
    ViewVariantInspector.tsx             同形
    

    label 与 value 逐字相同 ⇒ 没有任何标记,也就是「方向二」在本地被实现了:「已存」与「可选」在屏幕上变成同一幅画。

    ⭐ 根因值得记住:我数的是「解法」,不是「问题」。 三处是已经被人手修过的,所以有 (not found) / (deprecated) 这样可 grep 的标记;那五处没有标记,正是因为它们没被修过 —— 它们因此对任何搜标记的探针都是隐形的。一个按「修复痕迹」寻找同类项的探针,系统性地看不见未被修过的那些。

    ⛔ 卡片正文也有一处断言被证伪

    正文说 datasetName「leaves the binding blank rather than saying so」。不成立 —— :258 早就在 append 了。绑定从来不是空白的,是没有标记。两种缺陷,修的是后者。

    ⭐ 45 vs 61 的争议解决了,而且两个数字都是对的

    <InspectorSelectField  全部 JSX 开标签   → 61  (15 文件)
                           *.test.tsx 内     → 16  (2 文件)
                           非测试            → 45  (13 文件)
                           45 + 16 = 61
    

    ⇒ 两份测量都没数错,它们数的是不同的 population,而两边都没说自己数的是哪个。 这场争议横跨两个座位、两张卡。派发里那句「重推时把读法写出来,别只给数字」就是为此,它这一次真的付了钱。

    交付里超出裁决的两处,都记一笔

    • 消融的不对称性才是它的价值:五条按用例名变红(失败文本就是缺陷本身,expected '' to be 'retired_group (not found)'),而 InspectorSelectField's placeholder is unreachable at all 45 call sites #8450 的十条空态用例全部保持绿色,另加三条非回归用例。⇒ 钉子只为这个缺陷自己的理由而红。
    • (deprecated) 被保留为可覆盖而不是被抹平 —— 那处措辞有 framework#4278 / ADR-0090 D3 的出处。裁决要的就是「规则统一、句子不统一」。

    后续,已在别处

    • objectui#8862(本 PR 的 dev 立):primitive 分不清「roster 是空的」与「roster 还没答」,异步 picker 在飞时合法值会短暂戴上标记。⭐ 正确地标注为继承而非引入 —— ViewColumnInspector 的手搓副本在 main 上今天就能复现。
    • FlowObjectListField.tsx 的第九份副本走自己的 Radix select,⛔ 在本卡范围外。
    • 后缀的 i18n 是真的后续(engine.form.notInObject 已有 zh-CN 条目),noted 而非 filed。

    Generated by Claude Code

  6. removed their assignment
    on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpackage: app-shellpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions