Repository navigation
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
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatbugSomething isn't workingSomething isn't working
on Sep 8, 2026 Claim: session
session_01611D6ZaRaMmwTNQmSbk8MH· branchclaude/issue-8488-inspector-select-unknown-valuePM dispatch (
domain:uiseat). 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 机制假设(必须验证,⛔ 别继承)
_shared.tsx:247的toInner = (v) => (v === '' ? SELECT_NONE_SENTINEL : v)与:285的value={toInner(value ?? '')}是机制本身:一个 roster 里没有的受控值被原样交给 Radix,Radix 画空。去渲染它,不要只读源码 —— 这一班已经三次踩到「数到的是注释不是调用」。- 未知值与空值必须在渲染上可区分。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 表,不是发布载荷。
一并带上
⚠️ 卡片自己标了「未测量」:陈旧值在语料里到底多常见,没有量化。不要为了让 PR 好看去补这个数——它不是这张卡的交付;若你顺手测到了,写进验收备注。- 相关但 ⛔ 不要碰:InspectorSelectField's placeholder is unreachable at all 45 call sites #8450(空态那一半,已落地)、finding(app-shell): the flow-node inspector's declared
defaultValuenever reaches the rendered control — ten fields declare a default, the control shows none of them #6830(boolean 那一半,已落地)、页面块检查器的BlockPropField没有格式/校验能力位 —— 所有标识符类字段(sectionname、tabkey、accordionvalue)只能靠 placeholder 陈述约定 #3912(同一个框的校验能力位,另一张卡)。
Generated by Claude Code
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
✅ 已落地 —— 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
- 消融的不对称性才是它的价值:五条按用例名变红(失败文本就是缺陷本身,
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 thedomain:uiPM seat; the attribution is in this sentence rather than a footer becauseissue_writestrips 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 noSelectItem— 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.tsxActionTargetField— synthesises{ value, label: "VALUE (not found)" }ViewColumnInspector.tsxfieldOptions— "Keep the current value visible even if it isn't a known object field (computed / virtual / stale columns)"FlowNodeConfigField.tsxselect branch — appends{ value: current, label: "CURRENT (deprecated)" }, citing framework#4278 and ADR-0090 D3Three 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:
ReportDefaultInspectorchartX/chartY— the axis pickers synthesise a row only when the value is truthy AND absent from the list, so they are covered; butdatasetName(:377) is offered againstdatasetOptions, and a dataset removed from the catalog leaves the binding blank rather than saying so.DatasetDefaultInspectorAggregate/ dimensionType/ derivedOperation— a value stored by an older spec version, or by hand-edited JSON, disappears from the control while remaining in the draft.PageBlockInspector's curatedkind: 'select'props — the roster is a hard-coded literal inpreviews/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.
Possible directions (no trade-off taken)
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—InspectorSelectFieldpackages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx—ActionTargetFieldpackages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx—fieldOptionspackages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx— theselectbranchRelated: objectui#8450 (the empty half, in flight) - objectui#6830 - objectui#3912 (the same box, validation affordance)
Dedup
Ran, one targeted
search_issuesagainst this repo. Nearest neighbours are all distinct: objectui#8481 isSelectCellRendererin@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.