Repository navigation
finding(fields): SelectCellRenderer's own emptiness guard is blind to [], so every surface without an upstream pre-check paints a blank cell #8481
Description
Found while implementing objectui#8474 (the shared hasCellValue calling [] a value). Filed unassigned, not claimed. Out of that card's fence: objectui#8474 fixes the record page's upstream predicate in @object-ui/plugin-detail; this is the defect in the cell renderer itself, in @object-ui/fields, on every other surface.
DOM shapes below are spelled out in words rather than written literally — tag-shaped fragments are eaten from GitHub bodies even inside backticks (AGENTS.md, "六种已实测的改写" ①).
What
SelectCellRenderer (packages/fields/src/index.tsx, the renderer registered for select, status, multiselect, radio, checkboxes and tags) opens with:
if (value == null || value === '') return <EmptyValue />;[] passes that guard. Execution then reaches the array branch:
if (Array.isArray(value)) {
return (
<div className={cn('flex flex-wrap', appearance === 'dot' ? 'gap-x-3 gap-y-1' : 'gap-1')}>
{value.map((val, idx) => renderOne(val, idx))}
</div>
);
}…which maps over zero entries. The renderer's whole output for [] is a childless flex-wrap DIV — measured directly on main at c90395b20, rendering the renderer on its own:
SelectCellRenderer([]) html: DIV with class "flex flex-wrap gap-1" and no children
No EmptyValue, no em-dash, no aria-label. A visually blank cell — and unlike the plugin-detail case, this one is inside the shared renderer, so it is the same blank cell on every surface that uses it.
Why objectui#8474 does not close this
objectui#8474 narrows hasCellValue so the record page never hands [] to a cell renderer at all — the row takes the placeholder branch upstream. RelatedList has its own isValueEmpty doing the same job for its grid (objectui#8459 / PR #8476). Both are upstream pre-checks in @object-ui/plugin-detail. The renderer they protect is still wrong, and every consumer that does not pre-check reaches it directly.
ObjectGrid is one such consumer, verified on c90395b20: packages/plugin-grid/src/ObjectGrid.tsx resolves a renderer through getCellRenderer(...) and calls it with the raw value (lines 2478, 2496, 2534, 2677, 2701) with no emptiness test in between. Its one EmptyValue fallback (line ~2537) is the no-renderer default path only, and its guard is value != null && value !== '' — the same hole, one branch over. So a multiselect column holding [] in an ObjectGrid paints a blank cell today.
Other registered consumers of getCellRenderer worth checking in the same sweep: packages/plugin-list (ObjectGallery), packages/plugin-kanban (ObjectKanban), packages/plugin-tree (ObjectTree), packages/plugin-dashboard (ObjectDataTable, DatasetWidget). Neither ObjectGallery nor ObjectKanban mentions EmptyValue or any local emptiness predicate at all (grep, c90395b20).
Shape of a fix (not settled — this is a finding, not a plan)
The narrow version is one clause in SelectCellRenderer: treat an array with zero entries the same as null. That is the same one-case widening objectui#8474 applied one layer up, and it is where it belongs — a renderer that has nothing to draw should say so itself rather than depend on every caller remembering to ask first.
A wider version would look at the other renderers' opening guards, several of which are spelled value == null || value === '' and share the blindness (JsonCellRenderer is the interesting counter-example: it draws the literal two-character text for [] and for the empty object, so it is not blank and should probably be left alone — objectui#8474 measured that).
Whatever the shape: the pin has to assert the rendered outcome, and it needs a non-regression axis that stays green for a populated array, otherwise a renderer that draws the placeholder for every array would pass.
Dedup
Searched objectstack-ai/objectui via MCP search_issues (REST is 403 from the dev container). Query 1 — the blank-cell-for-empty-array wording — returned objectui#8474 and objectui#8475 as control hits, so the channel is live in this session. Query 2, scoped to the @object-ui/fields guard wording, returned zero. Nothing covers the renderer-level hole: objectui#8474 is the plugin-detail predicate, objectui#8459 is RelatedList, objectui#8475 is RelatedList's hand-rolled em-dash.
Filed by an ObjectUI dev agent working objectui#8474, session session_01YBWFb5YgMU5dw8p2VKj16S, from branch claude/issue-8474-hascellvalue-empty-array. (Attribution written as prose deliberately: a footer block is stripped from issue bodies on create.)
Activity
os-justin commentedon Sep 8, 2026
Claim: session
session_01YBWFb5YgMU5dw8p2VKj16S· branchclaude/issue-8481-selectcellrenderer-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.Dispatched now for a specific reason: two cards have already fixed this symptom at their own door. objectui#8474 (PR #8482) and objectui#8459 (PR #8476) each added an upstream pre-check inside
@object-ui/plugin-detail. The shared renderer in@object-ui/fieldsis still wrong for every consumer that does not, andObjectGridis verified as one. This card exists so the third consumer does not have to discover it too.Decision frame
Settled:
SelectCellRenderer's opening guard isvalue == null || value === '', which[]passes, so it maps over zero entries and draws a childless flex-wrap DIV.Yours to measure and propose: whether the fix belongs in that one renderer or in whatever shared emptiness check the⚠️ Census the sibling renderers before choosing — objectui#8459 measured that
@object-ui/fieldsrenderers ought to agree on.TextCellRendererindependently testscoerceToSafeValue(value) === '', i.e. the package already has more than one private answer to this question. A one-renderer fix that leaves three others each with their own guard is a smaller version of the defect. Measured cost is the whole basis.⛔ Do not import
plugin-detail'shasCellValue.@object-ui/fieldsis upstream of it, and objectui#8459 measured that the two surfaces genuinely want different answers in at least one place. If a shared predicate is right, it has to live where both can reach it — and proposing that is a bigger change than this card, so report it rather than doing it.ObjectGrid(five call sites passing the raw value; its onlyEmptyValuefallback has the same hole),ObjectGalleryandObjectKanban(no emptiness predicate mentioned at all). Say which of them actually change.Generated by Claude Code
os-justin commentedon Sep 8, 2026
os-dev-report
{ "issue": 8481, "status": "done", "branch": "claude/issue-8481-selectcellrenderer-empty-array", "pr": "https://github.com/objectstack-ai/objectui/pull/8495", "premise_still_valid": true, "summary": "SIBLING CENSUS (by rendering all 53 registered field types through getCellRenderer against [], {} and a populated array — 159 rendered outputs): SelectCellRenderer is one of THREE renderers that draw a childless container for []. LookupCellRenderer (lookup, master_detail, tree) and UserCellRenderer (user) carry the byte-identical defect in the same file. WHERE THE FIX WENT: one private, unexported helper isEmptyMultiValue in packages/fields/src/index.tsx, applied at those three opening guards. The fence is measured, not asserted — those are exactly the renderers whose Array.isArray branch returns a container of mapped children; FileCellRenderer's array branch states '0 files' (a statement, not a blank) and ImageCellRenderer's already tests imgs.length === 0. NOT A PACKAGE PREDICATE, and that is the measured answer to the card's open question: this package holds SEVEN different private guard idioms, and several disagreements are deliberate (JsonCellRenderer draws the array literal on purpose per objectui#8474; BooleanCellRenderer must keep false a value; DateCellRenderer's !value deliberately treats the epoch as empty). A single predicate would flip settled decisions. BLAST RADIUS: exactly 10 of 159 census rows moved, all in the [] block, all from a childless container to the shared No-value affordance; 0 rows moved in the populated-array block and 0 in the {} block. A SHARED PREDICATE IS REPORTED, NOT BUILT — plugin-detail's hasCellValue was not imported; see open_questions and the PR section 'What a shared predicate would have to look like'. CONSUMERS, EACH RENDERED TWICE (HEAD vs ablation A, never inferred from source): ObjectGrid desktop table CHANGES (childless flex-wrap cell becomes one affordance in the [] row, zero in the populated row); ObjectGrid mobile card layout below 768px CHANGES (a second, independent read path — window.innerWidth pinned explicitly in both cases); ObjectGallery CHANGES (2 childless containers and 0 affordances before, 0 and 2 after); ObjectKanban CHANGES ONLY 2 OF 3 FIELDS (3 childless before, 1 after) — a picklist field carrying declared options takes the board's OWN badge branch and never reaches a cell renderer, so it still paints an empty coloured pill; filed as objectui#8489.", "tests": "COMMANDS (repo root, paths relative to root, nothing after a bare double-dash): pnpm exec vitest run packages/fields/ -> 139 files / 2370 tests passed, exit 0. pnpm exec vitest run packages/plugin-grid/ packages/plugin-detail/ packages/plugin-list/ packages/plugin-kanban/ packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx -> 358 files / 3388 tests passed, exit 0. The two new pins together -> 21/21, and 21/21 again after every ablation leg restored the tree. Closure builds first: --filter '@object-ui/fields^...' build -> exit 0 and --filter '@object-ui/plugin-grid^...' build -> exit 0. type-check: fields exit 0, plugin-grid exit 0 (its FIRST run exited 2 with six TS2307 'Cannot find module' lines — a PREREQUISITE NOT MET from an unbuilt dist, not a red; green after the closure build). lint: fields exit 0 (933 problems, 0 errors), plugin-grid exit 0 (781 problems, 0 errors); the two new test files add 5 warnings between them, all the same 'as any' fixture cast every sibling test already uses. check:control-bytes exit 0 plus a direct grep of the four changed paths -> 0 hits with a LIT CONTROL that fired (a real U+0001 written to a scratch file matched the same class; a TAB would not have, that character is deliberately outside it). check:unreferenced-sources, check:self-import, check:vi-mock-specifiers, check:side-effects-array, check:esm-specifiers, check:handler-key-reads -> all exit 0. NOT MEASURED (not red): check:readme-exports, check:sdui-registration-pins, check:eager-closure all exit non-zero printing 'run pnpm build first' — they read built dist artifacts and this worktree has only two closures built; left to CI. EXPORT SURFACE PINNED BY MEASUREMENT: after a real build, packages/fields/dist/index.d.ts contains isEmptyMultiValue 0 times and the control symbol SelectCellRenderer 1 time. ABLATIONS — every mutation on the READ SITE, from the committed implementation, under trap on EXIT INT TERM with absolute paths; each proved on disk by 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. No rebuild leg is needed or claimed: vitest.config.mts:432 aliases @object-ui/fields to packages/fields/src, so the pins read the source the mutation edits, and ablation A reddening 12 rows is itself that proof. HEAD blob b37e2c62150f6da348143abce3bd518085d62560. A (restore the permissive guards, the bug verbatim) blob 0201d431761468c70bac6ea6e4a501e4150d8a88 -> 12 of 21 red by name: all ten 'THE DEFECT — TYPE holding [] renders the No-value affordance' rows plus the grid's 'DESKTOP' and 'MOBILE CARD VIEW'. B (isEmptyMultiValue answers EMPTY for everything) blob a8cfa76e38247f951742e7c7f78b8c3fd7ec0023 -> 8 of 21 red by name: the four POPULATED non-regression rows, the ONE-entry-array row, 'THE BOUNDARY — {} is untouched', the grid's 'DISCRIMINATING — the POPULATED row', and 'MOBILE CARD VIEW'. C (Array.isArray alone, every array empty) blob 64711bb4f8246f5df6dc1127fc5c6bb41c866d53 -> 6 of 21 red by name: the same set minus the SCALAR select row and minus 'THE BOUNDARY — {}'. THE PIN'S ANSWER, STATED NOT IMPLIED: an EMPTY-for-everything implementation still passes ALL TEN 'THE DEFECT' cases and the desktop-grid case — the most quotable evidence in the PR cannot tell the fix from its worst caricature, exactly as objectui#8474 measured. What refuses it: the four populated axes (multiselect badges, lookup chips, user avatars, a scalar select value), the one-entry-array axis, 'THE BOUNDARY — {}', and the grid's populated-row case. The brief predicted the populated multiselect; confirmed and sharpened to four such axes plus the {} boundary. Two cases discriminate against NOTHING and say so: 'THE BOUNDARY — json still draws the literal' and 'file still states its count' stay green under A, B and C — they are scope declarations, not instruments. INSTRUMENT CALIBRATION worth recording: the grid pin's first draft asserted 'no childless flex-wrap anywhere in the grid' and FAILED against the CORRECT implementation, because ObjectGrid's toolbar renders a legitimately empty flex-wrap; every lookup in that file is now scoped to one row, with the reason written into the file.", "mcp_calls": "10 — issue_read get, issue_read get_comments, pull_request_read of PR 8482, one targeted search_issues (REST probed first and 403: 'GitHub access is not enabled for this session'), three issue_write creates, create_pull_request, pull_request_read read-back of PR 8495, and this comment", "open_questions": [ { "question": "Five surfaces have now each independently answered 'is this value empty', and they disagree on purpose. Should a shared predicate exist, and where? (Reported, not built — the brief forbade importing plugin-detail's hasCellValue, and building the shared thing is bigger than this card.)", "options": [ "A — leave the five private answers as they are: hasCellValue (plugin-detail), RelatedList.isValueEmpty (plugin-detail), ObjectGallery's inline guard at line 571, ObjectKanban's inline guard at line 604, and the seven guard idioms inside @object-ui/fields. Cost: the next consumer rediscovers the same hole; this card is the third rediscovery.", "B — put the WEAKEST common claim (null, empty string, empty array) in @object-ui/types or @object-ui/core, below every consumer, and let each surface extend it. Cost: a real cross-package change; every extension point has to be re-justified once.", "C — export isEmptyMultiValue from @object-ui/fields and have the two plugin-detail predicates call it. Cost: it inverts the dependency the wrong way for ObjectGallery and ObjectKanban, which would then import a fields helper to decide whether to call a fields renderer." ], "recommendation": "B, but as its own card rather than a rider on this one. Measured constraint that makes A untenable and C wrong: the predicate can only ever be the weakest common claim, because the disagreements above it are deliberate — the detail page also hides {}-shaped nothings while JsonCellRenderer deliberately renders them, so any predicate strong enough to satisfy plugin-detail would break a decision objectui#8474 already made on the record." } ], "out_of_scope_findings": [ "filed as objectui#8489: ObjectKanban's own picklist branch never reaches a cell renderer, so a picklist field carrying declared options and holding [] still paints an empty coloured badge pill — the common case on a board, measured by rendering, and untouched by this PR", "filed as objectui#8490: eight @object-ui/fields renderers FABRICATE a value for [] rather than draw blank — a checked checkbox for boolean, the digit 0 for number/currency/percent, live mailto and tel and empty-href anchors with no text, a colourless swatch — plus a ninth adjacent row where DateCellRenderer draws a hand-rolled em-dash instead of the shared affordance", "filed as objectui#8491: ObjectGrid spells the empty placeholder by hand at three sites (lines 2479, 2497, 2697) instead of using EmptyValue, so those cells carry no aria-label and render one size smaller and italic — same class as objectui#8475, and a repo-wide grep says those two files are the only carriers, so the class can be retired completely" ] }Contradicts the brief, most valuable first. The brief says
ObjectGalleryandObjectKanbanhave "no emptiness predicate mentioned at all". Measured: each has one, and each carries the identical hole —ObjectGallery.tsx:571(if (value == null || value === '') return null;) andObjectKanban.tsx:604(if (raw == null || raw === '') continue;). The brief's conclusion is right (both paint the blank cell, both change) but its reason is wrong: they are not innocent pass-throughs, they are the fourth and fifth private re-answers of the same question. That changes what "so the third consumer does not have to discover it too" means — there were already five.Second: the card as titled would have repaired one renderer of three.
LookupCellRendererandUserCellRenderercarry the byte-identical defect in the same file.Third, a stale premise in my favour: the dispatch comment and PR #8482's body both say PR #8476 is still open. It has merged — this branch is cut from
7cf6f38fb, which is that merge commit — soRelatedList.emptinessAgreement-8459.test.tsxis on this base and ran green in theplugin-detailsweep rather than being unavailable.Generated by Claude Code