Repository navigation
finding(plugin-detail): the SHARED emptiness authority calls an EMPTY ARRAY a value — the record page paints a blank row for [], the exact defect the authority exists to prevent #8474
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_01YBWFb5YgMU5dw8p2VKj16S· branchclaude/issue-8474-hascellvalue-empty-arrayPM dispatch (
domain:uiseat). The 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.Prioritised because this is a defect in the authority three landed cards converged onto, found by the one card that declined to converge onto it (objectui#8459 / PR #8476).
Decision frame
Settled:
hasCellValue'stypeof value === 'object'short-circuit is too coarse by exactly one case —typeof [] === 'object', so an empty array reads as a value. objectui#8459's ablation C measured the consequence directly.Yours to measure and propose: whether the fix is a narrow
Array.isArray(value) && value.length === 0arm, or something that also reconsiders the other object shapes ({}in particular — measure it, do not assume it behaves like[]). Measured cost is the whole basis.⚠️ The blast radius is the point, and it is now three surfaces wide.hasCellValueis read byDetailSection's affordance, itsemptyCount/auto-hide, andcanCopy; PR #8457 extracts it topackages/plugin-detail/src/emptiness.tsand PR #8476 has just pinnedRelatedList's local predicate as deliberately different. Cut from currentmainand re-derive — several of those are in the merge queue.⚠️ The full objectui#8376 triple was measured for[]on the record page: it escapesemptyCount,canCopyoffers to copy it, and it can arm auto-hide on its own (shouldAutoHideEmptyneeds onlyfilledCount > 0). A fixture showing one blank row misses the amplification — build the one that drives auto-hide, the way PR #8396's did.⚠️ Do not "fix"RelatedListto match. PR #8476 measured that its local predicate is better shaped for a grid and pinned it. If your change makes the two agree, that must be because the shared one moved toward the local one, not the reverse.⚠️ Ask whether an implementation strictly worse than the bug would pass: a predicate answering EMPTY for everything satisfies every "the[]row draws the em-dash" assertion. objectui#8459's ablation B is the template — its two non-regression axes (0is a value; a populated object is a value) are what refuse it.
Generated by Claude Code
- added a commit that references this issue
on Sep 8, 2026 os-dev-report
{ "issue": 8474, "status": "done", "branch": "claude/issue-8474-hascellvalue-empty-array", "pr": "https://github.com/objectstack-ai/objectui/pull/8482", "premise_still_valid": true, "base": "c90395b20 (final commit a0ccd5f1c)", "summary": "hasCellValue's object short-circuit was too coarse by exactly the one case the card names. Fixed with a narrow arm INSIDE the object branch: `if (Array.isArray(value) && value.length === 0) return false;`. {} was measured on the DOM and deliberately did NOT move. Three files changed: emptiness.ts, a 9-case DOM pin, a minor changeset. RelatedList is untouched.", "where_the_predicate_lives": "packages/plugin-detail/src/emptiness.ts. The brief said PR #8457 'extracts' it and is in the merge queue — it has LANDED, as d7fecfbcf, so the extracted file is real on main and no PR-ref reading was needed. DetailSection.tsx only imports it now. PR #8476, by contrast, is still OPEN (mergeable_state behind), so its pin file RelatedList.emptinessAgreement-8459.test.tsx is NOT on this base and nothing here leans on it.", "what_empty_object_measured": "{} is a VALUE, and it stayed one. Rendered in a real DetailSection, {} on a json field, an object field AND a location field all draw the literal two-character JSON text through JsonCellRenderer (location falls back to it when the lat/lng chain yields nothing). It DRAWS — there is no blank cell, so none of the defect this card is about is present, and turning a visible {} into an em-dash would be a taste change dressed up as a bug fix. Separately, the shape that would have swept it in is unsafe: Object.keys(value).length === 0 is ALSO true of new Date(0), of a Map with one entry, of a Set with one entry, and of a class instance whose state sits behind getters (all four measured) — a false-empty on values that render, strictly worse for those shapes than the bug. Both halves are asserted in the pin so a future widening must delete an assertion, not merely forget a consideration.", "three_consumers_before_after": { "DetailSection_affordance": "BEFORE: [] fell through to SelectCellRenderer, whose own guard is `value == null || value === ''` — [] passes it — and which then maps over zero entries, so the cell was a childless flex-wrap DIV. Full section text `\"...industryManufacturingtags\"`, ZERO em-dashes, ZERO 'No value' affordances. AFTER: `\"...tags-em-dash\"`, one affordance carrying aria-label and title 'No value'.", "DetailSection_emptyCount_autohide": "BEFORE: [] counted as FILLED. 4 fields with tags:[] plus one absent read `Show 1 empty fields` and the [] row stayed visible and uncounted. AFTER: `Show 2 empty fields`, hidden with the rest, revealed by the toggle.", "DetailSection_canCopy": "BEFORE: copy button present on the [] row — it offered to copy an empty array next to a value it had not drawn. AFTER: withdrawn, matching the affordance.", "other_two_readers": "HeaderHighlight, DetailView summary chips, HistoryTimeline and RecordMetaFooter all move the same way for free — hasCellValue is not exported outside @object-ui/plugin-detail (grep across the workspace), so the blast radius is exactly its five documented readers." }, "amplification_pin": "Case 4 of the pin, and it is the fixture that drives auto-hide rather than one blank row. 4 fields, only tags:[] non-null. BEFORE (measured on c90395b20): full text `\"Detailstags\"` plus `Show 3 empty fields` — shouldAutoHideEmpty needs only filledCount greater than zero, so the lone [] armed auto-hide by itself and buried all THREE genuinely empty rows, leaving one label over a blank. AFTER: the four-row all-empty skeleton survives, four affordances, no toggle offered. window.innerWidth pinned to 1280 so the desktop thresholds (4 / 0.25) are the ones under test (objectui#8399).", "would_empty_for_everything_pass": "No — and the interesting part is WHICH case catches it. Ablation B (hasCellValue returns false for everything) reddens 8 of 9, but the one case it does NOT redden is the star AMPLIFICATION case: an EMPTY-for-everything predicate also leaves the all-empty skeleton standing. So the most impressive-looking assertion in the file is precisely the one that cannot tell the fix from its worst caricature. The cases that refuse B are the two non-regression axes (a POPULATED array plus a populated object; `0` and `false`) and THE BOUNDARY ({} is a value). A second caricature is refused too — ablation C, `Array.isArray(value)` alone so every array is EMPTY — and only the populated-array axis catches that one.", "ablation": { "method": "Mutations on the READ SITE (packages/plugin-detail/src/emptiness.ts), from the COMMITTED implementation, each proved on disk by blob hash AND by removed/injected grep counts in BOTH directions with the matched line printed; each restore verified BY STATE (git hash-object equal to git rev-parse HEAD:PATH, and git diff HEAD empty), never by an exit code; all legs under trap on EXIT INT TERM with absolute paths.", "head_blob": "91e9fdde8ae7672e40e9ceef51bd3a007dc0bddf", "A_restore_the_coarse_short_circuit": { "mutated_blob": "f119dc261ecd97b279e349051b1513300066acfa", "grep_proof": "implementation line 1 -> 0, injected marker 0 -> 1 (matched line printed at emptiness.ts:142)", "red_by_name": ["AFFORDANCE — an empty array draws the `No value` em-dash, not a blank cell", "COPY AFFORDANCE — the row that says `No value` does not also offer to copy it", "COUNTER — the empty-array row is counted among the empty fields, and revealing them reveals IT", "star AMPLIFICATION — one empty array must not suppress the all-empty skeleton", "DECLARED COST — a `json` field holding `[]` now draws the placeholder instead of the literal empty-array text", "MEASUREMENT — the arm is `Array.isArray && length === 0`, and the wider shape it rejects is unsafe"], "tally": "6 failed | 3 passed (9)" }, "B_empty_for_everything": { "mutated_blob": "b5230f00db5c68029dec3be447c4ad1ecf6212fa", "grep_proof": "injected marker 0 -> 1 (matched line printed at emptiness.ts:139)", "red_by_name": ["AFFORDANCE", "COPY AFFORDANCE", "COUNTER", "NON-REGRESSION — a POPULATED array is still a value, and so is a populated object", "NON-REGRESSION — `0` and `false` are still values", "THE BOUNDARY — `{}` is a VALUE: it DRAWS, so it did not move", "DECLARED COST", "MEASUREMENT"], "tally": "8 failed | 1 passed (9) — the survivor is star AMPLIFICATION, see would_empty_for_everything_pass" }, "C_every_array_empty": { "mutated_blob": "7dbf02ed52b73b8a00fb1d396d102eb0ee305fa8", "grep_proof": "implementation line 1 -> 0, injected marker 0 -> 1 (matched line printed at emptiness.ts:148)", "red_by_name": ["NON-REGRESSION — a POPULATED array is still a value, and so is a populated object", "MEASUREMENT — the arm is `Array.isArray && length === 0`, and the wider shape it rejects is unsafe"], "tally": "2 failed | 7 passed (9)" }, "restore": "All three legs: restored blob hash equal to HEAD blob 91e9fdde8ae7672e40e9ceef51bd3a007dc0bddf, git diff HEAD empty, implementation line back to 1 occurrence, injected marker back to 0. Tree confirmed clean afterwards and the pin re-run green 9/9." }, "relatedlist_untouched": "CONFIRMED. git diff touches exactly three paths and RelatedList.tsx is not among them: .changeset/8474-empty-array-is-not-a-cell-value.md, packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx, packages/plugin-detail/src/emptiness.ts. RelatedList.isValueEmpty at RelatedList.tsx:913-917 is byte-unchanged. The two predicates now agree on every probe measured ([] empty; {}, [1], {a:1}, 0 and a Date all values) BECAUSE THE SHARED AUTHORITY MOVED TOWARD THE LOCAL ONE — the direction the brief mandates. PR #8476's measurement stands untouched.", "changeset": "@object-ui/plugin-detail: minor — matching the landed precedent for a plugin-detail render change (objectui#8457, and PR #8476 for the same surface). Gate verdict line, quoted: `1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/8474-empty-array-is-not-a-cell-value.md.` check-changeset-no-major.mjs: `No changeset declares a major bump.` skip-changeset NOT applied — phantom label here, and the change is user-visible anyway. major is forbidden in this repo.", "tests": "Run from the repo root, paths relative to the root, nothing after a bare double-dash. (1) pnpm exec vitest run packages/plugin-detail/ -> 140 files / 1270 tests passed. (2) the new pin alone -> 9/9, and 9/9 again after the ablation harness restored the tree. (3) pnpm --filter @object-ui/plugin-detail run type-check -> exit 0 (tsc --noEmit plus tsc -p tsconfig.test.json). (4) same package run lint -> exit 0, '905 problems (0 errors, 905 warnings)', new test file contributes none. (5) closure build FIRST: pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build -> exit 0. (6) pnpm run check:control-bytes -> exit 0, plus a direct scan of the three changed paths whose lit control fired on an injected U+0001 and whose real scan exited 1 (clean). (7) pnpm run check:unreferenced-sources -> exit 0. (8) governed guard with the three paths passed explicitly -> 'NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.' DECLARED NARROWING: app-shell, console, plugin-kanban, plugin-calendar, plugin-gantt and examples/console-starter depend on plugin-detail; hasCellValue is not exported outside the package and no test outside it reads the 'No value' affordance (grep matches six files, none a detail-page render test). Ran five app-shell record-detail files anyway -> 5 files / 61 tests passed. Rest left to CI, declared not silently skipped.", "mcp_calls": "10 — issue_read get 8474, issue_read get_comments 8474, pull_request_read 8476, search_issues x2, issue_write create 8481, issue_read get 8481 (read-back), create_pull_request 8482, pull_request_read 8482 (read-back), add_issue_comment (this report).", "open_questions": [], "out_of_scope_findings": [ "filed as objectui#8481: SelectCellRenderer's OWN opening guard is `value == null || value === ''`, which [] passes, so the renderer itself draws a childless flex-wrap DIV. This card and PR #8476 both guard it with an upstream pre-check inside @object-ui/plugin-detail; the shared renderer in @object-ui/fields is still wrong for every consumer that does not. ObjectGrid is one, verified on c90395b20: it calls the resolved renderer with the raw value at five sites and its only EmptyValue fallback is the no-renderer default path, whose guard has the same hole. ObjectGallery and ObjectKanban mention no emptiness predicate at all." ], "contradicts_the_brief": [ "PREMISE STALE, conclusion unaffected: the brief says PR #8457 'extracts' hasCellValue to packages/plugin-detail/src/emptiness.ts and that several of these are in the merge queue. #8457 has LANDED (d7fecfbcf) — the file is on main and I edited it directly rather than reading a PR ref. The complementary half also inverted relative to PR #8476's report, which found emptiness.ts absent from main and had to read the PR ref: on my base it is present, and PR #8476 is the one still open. Neither changes what the fix should be.", "A BRIEF INSTRUCTION VINDICATED BY MEASUREMENT, with a twist worth recording: the brief warned that a fixture showing one blank row misses the amplification, and told me to build the one that drives auto-hide. Correct — but ablation B shows the amplification case is ALSO the single case an EMPTY-for-everything predicate does not redden. So the amplification fixture is necessary and is NOT sufficient, and citing it alone as proof against the naive caricature would be wrong. The non-regression axes are what carry that weight.", "A REPO-DOC CLAIM I COULD NOT REPRODUCE: objectui AGENTS.md's mutation (1) states tag-shaped fragments are eaten from GitHub bodies and that backticks and fenced code blocks do not protect them. On this session's issue_write create (objectui#8481) literal tag-shaped fragments inside fenced ts blocks came back INTACT on a read-back to the end of the body; the same holds for the PR body just created. One observation is not a refutation — the rule may describe an intermittent or endpoint-specific behaviour, and the failure mode it guards against is silent — so I kept the mitigation (shapes spelled out in words) everywhere anyway, since it costs nothing. Recording it because a rule nobody can reproduce eventually gets ignored for the wrong reason." ] }Generated by an ObjectUI dev agent for session
session_01YBWFb5YgMU5dw8p2VKj16S, branchclaude/issue-8474-hascellvalue-empty-array.
Generated by Claude Code
Found while implementing objectui#8459 (
RelatedList's two emptiness spellings). Filed unassigned, ⛔ not claimed. Out of that card's fence: it is a defect in the shared authority itself, on a different surface (the record page body), not in the related-list grid.What
hasCellValue— THE definition of emptiness for the record page (objectui#8376, widened by objectui#8394, extracted topackages/plugin-detail/src/emptiness.tsby PR #8457) — opens with:typeof [] === 'object', so an empty array is a VALUE. Nothing further is asked of it.Why that is wrong here, measured
The object half exists for a good reason, and its docblock states it: an object value is handed to a TYPE-AWARE renderer that knows how to draw it — coordinates, a postal address, select badges. But every example the docblock reasons about is a populated object;
[]is never considered. And a type-aware renderer has nothing to draw for it.SelectCellRenderertestsvalue == null || value === '', which[]passes, and then maps it over zero options.Measured on
origin/main(a407bd6), rendering a realDetailSectionwithtags: []on aselectfield:The label renders; the value area is empty. No
No valueaffordance, no em-dash — the visually blank cell the em-dash exists to prevent.The three consequences are the same three objectui#8376 names
Because all three of
DetailSection's reads share this one function, the empty array reproduces the full objectui#8376 triple:emptyCount, so "Show N empty fields" reads one too low and revealing the empty rows does not reveal this one;canCopyishasCellValue(value), so the row offers to copy an empty array next to a value it did not draw.shouldAutoHideEmptyneeds onlyfilledCount > 0, so a section whose only non-null value is[]can suppress the all-empty skeleton by itself — the objectui#8376 escalation, unchanged.PR #8457 does not fix it
The extracted
emptiness.tsin the merge queue carries the identical predicate (if (value !== null && typeof value === 'object') return true;), so this survives the extraction verbatim. Its docblock's reader list (DetailSection,HeaderHighlight,DetailViewsummary chips,HistoryTimeline,RecordMetaFooter) is the blast radius: each of those bands asks the same question and gets the same wrong answer for[].There is already a measured counter-example in the repo
RelatedList.isValueEmpty— the related-list grid's own predicate, and the convergence target chosen in objectui#8459 — draws the finer line and has for some time:objectui#8459 measured both directions end-to-end: an all-
[]column is correctly pruned there, and a surviving[]cell draws the placeholder. That card deliberately did not delegate tohasCellValuefor exactly this reason, and pins the difference inRelatedList.emptinessAgreement-8459.test.tsx(A SURVIVING EMPTY-ARRAY CELL DRAWS THE EM-DASH), which is red for thehasCellValueshape.Suggested shape (not settled — measurement should decide)
Narrow the object half from "every non-null object is a value" to "every non-empty object is a value": keep objects and populated arrays as values, and let
[]fall through to EMPTY.{}deserves its own measurement rather than being assumed —JsonCellRenderermay well draw{}visibly, in which case it is a value and only the array clause moves.Dedup
Searched
objectstack-ai/objectuivia MCPsearch_issues(REST is 403 from the dev container):RelatedList EmptyValue placeholder em-dash aria-label inconsistent hand-rolled spanandRelatedList two emptiness spellings disagree pruneEmpty makeCell. Both returned objectui#8459 as a control hit, so the readings are real. Nothing covers the empty-array hole in the authority: objectui#8376, objectui#8394 and objectui#8350 are all about whitespace-only strings.