Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(fields): SelectCellRenderer's own emptiness guard is blind to [], so every surface without an upstream pre-check paints a blank cell #8481

Closed

Description

@os-justin

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

  1. self-assigned this
    on Sep 8, 2026
  2. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    Claim: session session_01YBWFb5YgMU5dw8p2VKj16S · branch claude/issue-8481-selectcellrenderer-empty-array

    PM dispatch (domain:ui seat). 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/fields is still wrong for every consumer that does not, and ObjectGrid is verified as one. This card exists so the third consumer does not have to discover it too.

    Decision frame

    Settled: SelectCellRenderer's opening guard is value == 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 @object-ui/fields renderers ought to agree on. ⚠️ Census the sibling renderers before choosing — objectui#8459 measured that TextCellRenderer independently tests coerceToSafeValue(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's hasCellValue. @object-ui/fields is 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.

    ⚠️ Verify the downstream consumers by rendering, not by reading: ObjectGrid (five call sites passing the raw value; its only EmptyValue fallback has the same hole), ObjectGallery and ObjectKanban (no emptiness predicate mentioned at all). Say which of them actually change.

    ⚠️ The amplification-vs-discrimination lesson from objectui#8474 applies here too: the vivid assertion is rarely the discriminating one. Ask which of your cases an EMPTY-for-everything implementation would still pass, and make sure something else refuses it — a populated multiselect rendering its badges is the likely axis.


    Generated by Claude Code

  3. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    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 ObjectGallery and ObjectKanban have "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;) and ObjectKanban.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. LookupCellRenderer and UserCellRenderer carry 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 — so RelatedList.emptinessAgreement-8459.test.tsx is on this base and ran green in the plugin-detail sweep rather than being unavailable.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions