feat(core): isEmptyValue — the shared emptiness floor, and the surfaces that state their answer against it - #8981
Conversation
…ces that state their answer against it
Five surfaces each held their own answer to "is this value empty", and
objectui#8481 was the third rediscovery of the same hole. The weakest common
claim — null, undefined, the empty string, the empty array — now lives in
`@object-ui/core` as `isEmptyValue`, below every consumer.
The floor was not invented: `evaluator/optionRules.ts` had spelled exactly
those four members privately, and this promotes that copy.
Every surface that answers differently keeps its own answer, rewritten as an
explicit call on the floor with the justification at the site:
- `hasCellValue` and `RelatedList.isValueEmpty` extend it with a trim;
- `BooleanCellRenderer` extends it with every non-boolean (false stays a
value); the date cells with every falsy scalar (the epoch stays empty);
- `JsonCellRenderer` DECLINES its `[]` member — the array literal is drawn
on purpose — and `LocationCellRenderer` / `AddressCellRenderer` inherit
that through the JSON fallback; `FileCellRenderer` states "0 files".
Two visible fixes: a gallery card and a kanban card holding `[]` in a card
field now omit that field, as they already did for `null`, instead of drawing
a labelled "No value" em-dash beside fields that were omitted.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
ACCEPT + 席内 Clause-② 复核 PASS — 派发席
|
| 项 | 读数 |
|---|---|
| 门禁 | 34 个 check 全部收敛:31 success · 3 skipped · 0 红 · 0 悬。逐个 job 结论读取,⛔ 未用 get_status、⛔ 未认 required 子集 |
| 受管面 | Governed Surface Queue Guard = success;终报申报 8 路径 NOT GOVERNED |
| 范围 | 14 文件 +1036/−86,与申报一致 |
| 裁决落点 | 地板落 @object-ui/core,⛔ 不是 types 回退臂 —— 且依赖是测出来的:fields 的 dependencies 含 @object-ui/core,40 个文件实际 import,反向 0 条 import(只有注释散文)⇒ 无环、无新增依赖边。裁决的回退条款因此不触发,这正确 |
本席按内容亲核的四条(⛔ 不引用终报)
- 地板恰好四个成员 ——
packages/core/src/utils/emptiness.ts:97起:value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)。⛔ 无第五个。 - 确实从 barrel 发布 ——
packages/core/src/index.ts:32export * from './utils/emptiness.js'。⇒ 公开面扩大 ⇒Clause-②: yes成立,按机制而非继承。 - ⭐ 刻意的分歧没有被抹平 ——
isEmptyValue(value) && !Array.isArray(value)这个「当众拒绝一个成员」的形态在packages/fields/src/index.tsx命中 3 次(Json / Location / Address),与申报一致。 isEmptyMultiValue真的没了 ——origin/main上 5 处,分支上代码 0 处;仅存的 1 处命中是一段注释,记录它此前的拼写。⚠️ 本席第一次探针只数了字符串出现次数,读到 1 就以为申报有误 —— 字面计数不区分代码与散文,这是本仓已记录的同类探针缺陷(⑧)。补测后申报成立。
⭐ 本轮最有价值的产出:一条本席的派发假设被证伪,而且证伪方向是危险的那一侧
简报 ZONE 1.3 把四条「已被钉住的刻意分歧」当作既成事实交给 dev,并要求「⛔ 不得抹平」。dev 实测:DateCellRenderer 的 epoch-算空根本没有被任何测试钉住 —— 全树没有断言 date 持有 0,最近的一条 pin 断言的是相反方向(数值 epoch 1700000000000 仍会渲染)。
⇒ 也就是说本席点名保护的四条里,有一条当时正暴露在它自己被静默抹平的风险下,而简报却把它当成安全的。dev 在本 PR 里把它钉上了,消融 leg C 证明它现在会红(date holding 0: the epoch is empty here on purpose)。
消融(区分力)
三条腿,每条都先在盘上证明变异已落地(目标文本计数归零 + 注入标记 + git hash-object 与 HEAD blob 不同),再跑,再 git checkout HEAD -- path 还原并按状态证明还原(blob 等于 HEAD 且 git diff HEAD 为空):
- leg A 去掉
[]成员 ⇒ 四个 pin 文件全红,12 failed / 77 passed; - leg B 把
JsonCellRenderer压平成裸isEmptyValue(value)⇒ 7 红,逐条报expected '—' to contain '[]'; - leg C 把
DateCellRenderer的!value压平 ⇒ 1 红。
还原后 5 文件 / 104 断言全绿。⇒ 区分力由两个方向证明,⛔ 不是一句「测试通过」。
⚠️ 两处用户可见行为变更,在裁决射程内
gallery 与 kanban 的卡片字段持有 [] 时,现在省略该行,而不再画一个带标签的破折号 —— 与它们对 null 的既有行为一致。[] 是地板成员,而这两个内联判据正是裁决点名要采纳地板的面。changeset 已写明,且两个 pin 各配了「gallery / kanban 仍然不做 trim」的对照。
needs:contract-review —— 本席移除,理由在案
维护者裁决 objectstack-ai/objectstack#17285:契约复审档不归本车道,派发席默认档自审 + 门禁即本车道的 review of record。⇒ 留着该标签会让本 PR 等一个不会来的席位。本复核即是那份 review,PASS。
⇒ 放行:翻 ready、挂 SQUASH 自动合并,按队列 ref 阳性命中确认,再按内容带亮/死对照核落地。
Generated by Claude Code
Fixes #8496
Implements the landed ruling (comment 5581959569 — director seat, decision batch #86, 2026-09-08): option B, in
@object-ui/core.Clause-②: yes— cross-package change adding a published export; the PR carriesneeds:contract-review(added with the additive labels endpoint, read back as the union with the five the labeler set).check-governed-queue-guard.mjs --teston all eight touched paths reportsNOT GOVERNED — 8 path(s) checked against 5 governed surface(s); none matched, so the normal review and merge-queue route applies; this lane's in-seat default-tier review plus the gates is the review of record (objectstack#17285).The dependency measurement that decided the placement
The ruling made
coreconditional: fall back to@object-ui/typesif@object-ui/fieldsdoes not already depend oncore. Measured — it does, so no fallback.packages/fields/package.jsondependencies@object-ui/core(alongsidecomponents,i18n,providers,react,types)peerDependencies/devDependenciescoreentry in either; the edge is a plain runtime dependencyfields/srctocore@object-ui/core, includingpackages/fields/src/index.tsxitself (the barrel this PR edits)core/srctofieldsplugin-list/plugin-kanban/plugin-detailcoreThat last row is also why option C stays refused: the gallery and the kanban would have had to import a
fieldshelper to decide whether to call afieldsrenderer.Where the floor landed
packages/core/src/utils/emptiness.ts, exported from the barrel. Exactly four members and no fifth:null,undefined, the empty string, the empty array.The floor was not invented — it was promoted.
packages/core/src/evaluator/optionRules.tsalready held a module-privateisEmptyValuespelling byte-for-byte those four members, written before the card and never counted by it. The new module is that function, moved up and exported;optionRulesnow imports it. So this PR introduces zero new spellings of the four members — it removes five."Empty string" means
'', not "blank". That is forced by the weakest-common-claim rule:record:detailsandRelatedListtrim, and the gallery, the kanban and the shared renderers do not. A trimming floor would not be a floor. The trim is therefore an extension at two sites, and it is pinned as one.The surfaces, re-derived by anchor (the card's line numbers are from 2026-09-08)
plugin-detailhasCellValueemptiness.tspackages/plugin-detail/src/emptiness.tsplugin-detailRelatedList.isValueEmptyRelatedList.tsxconst isValueEmpty = (v: any) =>plugin-listinlineObjectGallery.tsx:571renderFields, drifted to :582; a second copy inresolveCoverUrlplugin-kanbaninlineObjectKanban.tsx:604explicitCardFieldsloop, drifted to :813; two more inresolveDisplayanddescMissingresolveDisplay); floor + opaque-id (descMissing)@object-ui/fieldsMore than five, as suspected — and the extra ones are the interesting half. Beyond
fields' count drifting from seven to ten,@object-ui/coreitself held two more private answers (optionRules, promoted here;dashboard-filters, a different question — it is keyed by a filterdefand answers "carries no constraint"). A wider census found the same four members spelled again inapp-shell/utils/auditHistoryDisplay.ts,app-shell/views/metadata-admin/inspectors/datasetFilterCondition.ts,fields/widgets/LookupField.tsx,plugin-grid/useColumnSummary.ts,plugin-grid/components/BulkActionDialog.tsx,plugin-gantt/ObjectGantt.tsx,core/validation/required-presence.ts,components/custom/filter-builder.tsxandcomponents/renderers/form/form.tsx. Those are noted, not filed and not touched: the ruling scoped this change to the five tabulated surfaces, none of them is a defect (each already answers the floor), and each is now a one-line swap for whoever picks up this card's follow-through.@object-ui/fields, guard by guardisEmptyValue(value)Select,Lookupvalue == null || value === '' || isEmptyMultiValue(value))isEmptyValue(safe)Text,FormulaisEmptyValue(value)ColorSwatchvalue == null;''/[]reached the same affordance one branch down)isEmptyValue(safe) || trimisBlankCellText→Number,Currency,Percent,Date,Email,Url,Phone,ColorSwatchisEmptyValue(value) || !valueUsertypeof value !== 'boolean'Booleanfalsestays a value (objectui#8582)!valuethen the blank/unparsable testDate,DateTime!valueFile[]DECLINED — it states "0 files"!value || imgs.length === 0ImageisEmptyValue(value) && !Array.isArray(value)Json,Location,Address[]DECLINED, out loud (objectui#8474)isEmptyMultiValueis gone: all three of its call sites were spelling the floor around it. Its measured reasoning (the childless-container defect, and the catalogue of renderers that disagree) is carried forward — the catalogue now sits as a roster at the head ofisBlankCellText, and the childless-container paragraph atSelectCellRenderer's guard.Two visible fixes came with it
A gallery card and a kanban card holding
[]in a card field now OMIT that field, exactly as they already did fornull. Before,[]fell through the private guard to the shared renderer, which since objectui#8481 answers it with the "No value" em-dash — so one card could draw a labelled placeholder fortags: []while silently omitting thenullfield beside it. Both are pinned.The anti-flattening assertions (the load-bearing half)
A green suite that only proves the floor works proves the cheap half. Every case below asserts a disagreement:
packages/core/src/utils/__tests__/emptiness-floor-8496.test.ts— the four members; 14 refused fifth members each with the measurement that refused it (' ',{},0,false,new Date(0), a populatedMap/Set, a class instance behind getters, …); a count assertion so a widening cannot pass unnoticed; and the promotion's non-regression evidence throughisOptionGroupGated/isValueStillOffered.packages/fields/src/__tests__/emptinessFloorExtensions-8496.test.tsx—json/object/composite/recordstill print the literal for[];location/geolocation/addressstill keep the unknown shape visible;file/video/audiostill state the count;booleancalls every non-boolean empty andfalsea value;date/datetimecall the numeric epoch empty;userextends with falsy; the coercing family trims andtextdeliberately does not.packages/plugin-detail/src/__tests__/emptinessFloorExtensions-8496.test.tsx— the trim as a stated disagreement with the floor, at the predicate and once at the DOM through the realRelatedListgrid; and the seven valueshasCellValuerefuses to let the floor grow.ObjectGallery.emptinessFloor-8496.test.tsx/ObjectKanban.emptinessFloor-8496.test.tsx— the[]omission, each with a control that the surface still does not trim.104 assertions across the five files.
Each of the four pinned disagreements, verified rather than assumed
JsonCellRendererdraws the array literalcellRenderers.emptyArray-8481.test.tsx, "THE BOUNDARY"BooleanCellRendererkeepsfalsea valuebooleanCell.nonBooleanScalar-8582.test.tsxandcellRenderers.fabricatedValue-8490.test.tsxDateCellRenderer's!valuetreats the epoch as emptydateholding0. The nearest pin asserts the opposite direction (a numeric epoch timestamp of1700000000000still renders). It is pinned now, in the fields file aboveRelatedList's local predicateRelatedList.emptinessAgreement-8459.test.tsx, DOM-levelReverse verification — the pins can fail, and they fail for the right reason
Run against the committed tree, one mutation at a time; each leg proved the edit reached disk (target-text count went to zero, injected marker appeared,
git hash-objectdiffered from theHEADblob) and each restore was proved by the blob matchingHEADagain with an emptygit diff HEAD. No mutation survives in the branch.[]member fromisEmptyValue12 failed / 77 passedJsonCellRendererto a bareisEmptyValue(value)json/object/composite/record/location/geolocation/addresscase, each reportingexpected '—' to contain '[]'DateCellRenderer's!valuetoisEmptyValue(value)date holding 0: the epoch is empty here on purpose,1 failed / 58 passedRestored tree, all five pin files re-run:
Test Files 5 passed (5) · Tests 104 passed (104).Gates — every exit code captured to disk before it was read
All on branch head
eb813566e.pnpm --filter '@object-ui/plugin-kanban^...' --filter '@object-ui/plugin-list^...' buildturbo run build $(check-doc-snippet-types.mjs --build-filter) --concurrency=2—35 successful, 35 totaltype-check(the 5 affected packages)Donecore+fieldsTest Files 294 passed (294) · Tests 5678 passed (5678)plugin-list+plugin-kanbanTest Files 121 passed (121) · Tests 1165 passed (1165)plugin-detailTest Files 160 passed (160) · Tests 1488 passed (1488)lint(the 5 affected packages)0 errorsin all five (2193 pre-existing warnings, none on a changed line)check-control-bytes.mjsscanned 7180 tracked text file(s); skipped 85 binarycheck-changeset-presence.mjs7 source file(s) of 5 released package(s) changed, and this change declares 1 changeset(s)check-governed-queue-guard.mjs --test(8 paths)NOT GOVERNED — 8 path(s) checked against 5 governed surface(s); none matchedcheck:new-line-citations0 new citation(s), enforcement report-onlycheck:doc-snippets638 of 638 block(s) judged, 0 failedcheck:doc-typesEvery documented component type is registeredcheck:doc-examples124 block(s) — 35 compile, 89 fail, 89 of those declared in the ledger. First run was exit 2 (PREREQUISITE NOT MET — unbuiltdist), re-run after the scoped build; no ledger row was displaced by this diffcheck:esm-specifierscheck:dist-completeness --all12 package(s) complete (1641 emitted files verified)check:readme-exportscheck:unreferenced-sourcesTotal across the affected suites: 575 test files, 8331 tests, 0 failures. The repo-wide
turbo run lint/turbo run testfarm is CI's run, not re-run here; the lint narrowing is at the package boundary (5 of 47), and it is a measurement rather than a skip because ESLint here is not type-aware —eslint.config.jssets noparserOptions.projectand noprojectService, so nothing in this diff can move the verdict on a file it did not touch.Assumptions checked and falsified
fieldsalone, two more insidecore, and nine more across the tree; the table above states each.DateCellRendererepoch, as above.emptyhas split into two concepts — say so and stop if the floor cannot serve both" — measured, and the floor serves both, so this proceeds. The split comment 5603203484 draws is real but it is one layer up: its evidence is which component states the emptiness (EmptyValue's fixed em-dash versusEmptyDescription's authored sentence) and atext-size census insideapp-shell/.../metadata-admin/previews/— a presentation axis, downstream of any predicate, on a surface this card does not tabulate. At the predicate layer the floor already IS both halves: two scalar-missing members, one blank scalar, one empty collection. No call site asks a boolean to tell them apart, because each one knows statically which affordance it is drawing. So no second axis was invented;emptiness.tssays so explicitly and points the component question at objectui#8570 / objectui#8526 / objectui#8507.GET /pulls?state=open(9 open PRs, every file enumerated). No open PR touches any file in this diff. Nearest neighbours are fix(plugin-detail): record:chatter.feed carries the affordance members, and says so (objectui#8934) #8962 (plugin-detail/src/index.tsx) and chore(deps): lucide-react 1.31.0 -> 1.43.0, with the one retired spelling repaired #8941 (fields/package.json,plugin-detail/src/DetailView.tsx) — different files. The snapshot in the dispatch is stale in both halves: objectui#8568's live PR is test(scripts): census the$-dialect lowercase aliases before objectui#8568 is ruled #8977, whose diff ispackage.jsonplus ascripts/census and its test — it does not touchcore/src/utils/filter-converter.tsor the README; and objectui#8976 has no open PR at all. The branchclaude/issue-8568-retire-lowercase-aliasesexists on the remote but carries no commits ahead of its merge-base.Acceptance notes
BulkActionDialogbelongs to objectui#8507's taxonomy, which is already open.@object-ui/core'sutils/dashboard-filters.tskeeps its own privateisEmptyValue: it takes a filterdefand answers "carries no constraint", which is a different question that happens to open with the same four members. Left alone rather than half-converted.EmailCellRenderer/UrlCellRenderer/PhoneCellRenderer/NumberCellRenderer/CurrencyCellRenderer/PercentCellRendererreach the floor transitively, throughisBlankCellText's explicit call. They get no second call of their own — that would be the dead disjunct again.kanbandescMissingandresolveDisplayconversions were in-scope-adjacent: both sit inside the same card-building function as the tabulated loop, both were the same four members spelled a third and fourth time in one file, and both are behaviour-identical apart fromdescMissingnow treatingdescription: []as missing (an empty array is not a subtitle).🤖 Generated with Claude Code
https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB