Skip to content

fix(fields): an empty array is not a cell value in the shared read renderers - #8495

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8481-selectcellrenderer-empty-array
Sep 8, 2026
Merged

fix(fields): an empty array is not a cell value in the shared read renderers#8495
os-justin merged 1 commit into
mainfrom
claude/issue-8481-selectcellrenderer-empty-array

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8481

Cut from main at 7cf6f38fb. Final commit c1337dfbb.

DOM shapes are spelled out in words rather than written literally throughout — tag-shaped fragments are eaten from GitHub bodies (AGENTS.md, "六种已实测的改写" ①). Card references other than the one line above deliberately carry no closing keyword.

Verdict: the CHILDLESS-CONTAINER class — three renderers, one private helper, not a package predicate

The card names SelectCellRenderer. Censused by rendering, it is one of three:

field types renderer output for [] on 7cf6f38fb
select, status, multiselect, radio, checkboxes, tags SelectCellRenderer a DIV classed flex flex-wrap gap-1, no children
lookup, master_detail, tree LookupCellRenderer a DIV classed flex flex-wrap gap-1, no children
user UserCellRenderer a DIV classed flex -space-x-2, no children

All three open a multi-value container and map their entries into it. That is the fence, and it is measured rather than asserted: those are exactly the renderers in this file whose Array.isArray branch returns a container of children. FileCellRenderer also has an array branch but returns the text 0 files — a statement, not a blank — and ImageCellRenderer's array branch already tests imgs.length === 0.

One private helper, isEmptyMultiValue, answers one question at those three opening guards. ⛔ Not exported, and deliberately not the package's general emptiness predicate — see the census below for why.

The census: this package holds SEVEN private answers to "is this empty"

Measured by rendering every one of the 53 registered field types through getCellRenderer against [], {} and ['alpha','beta'] — 159 rendered outputs, before and after.

guard idiom renderers
value == null || value === '' Select, Lookup, Json, ColorSwatch, Location, Address
coerceToSafeValue(value) == null || === '' Text, Formula
value == null Number, Currency, Percent, Boolean
!value Date, DateTime, Email, Url, Phone, File, User
!value || imgs.length === 0 Image
Array.isArray(v) ? v.length : 0 the repeater registry entry
Object.keys(v).length === 0 AddressCellRenderer's second clause

The brief asked whether the fix belongs in one renderer or in a shared check the package's renderers "ought to agree on". Measured answer: they must not all agree, and several disagreements are deliberate. JsonCellRenderer draws the two-character array literal on purpose (objectui#8474 measured that and kept it); FileCellRenderer states a count; BooleanCellRenderer must keep false a value while DateCellRenderer's !value deliberately treats the epoch as empty. A single predicate over all of them would flip settled decisions. ⇒ A shared predicate is reported, not built — see "What a shared predicate would have to look like" below.

Blast radius: 10 of 159 rendered outputs moved, and nothing else

Re-running the same census after the change, diffed row by row:

  • 10 rows changed, all in the [] block, all from a childless container to the shared No value affordance: select, status, multiselect, radio, checkboxes, tags, lookup, master_detail, tree, user.
  • 0 rows changed in the populated-array block.
  • 0 rows changed in the {} block.

The consumers, verified by RENDERING

Each row is a real component rendered twice against the same fixture: once at HEAD, once with ablation A applied (the permissive guards restored verbatim, proved on disk and restored by state). Neither leg is inferred from the source.

consumer its own pre-check BEFORE (ablation A) AFTER (HEAD)
ObjectGrid, desktop table none between getCellRenderer(...) and the raw value the [] row painted a childless flex-wrap cell one No value affordance in that row, zero in the populated row
ObjectGrid, card layout below 768px none same blank affordance present, populated card still draws its badge
ObjectGallery ⚠️ it has oneObjectGallery.tsx:571, if (value == null || value === '') return null; — with the SAME hole 2 childless containers (flex flex-wrap gap-1, flex -space-x-2), 0 affordances 0 childless, 2 affordances
ObjectKanban ⚠️ it has oneObjectKanban.tsx:604, if (raw == null || raw === '') continue; — same hole 3 childless containers, 0 affordances 1 childless, 2 affordances — 2 of 3 fixed, see below

In every leg the populated card still drew its Alpha badge, so no leg is measuring an all-empty renderer.

⚠️ ObjectKanban only PARTLY changes, and the part that does not is the common case. Its card-field loop forks four lines after that guard: any field carrying declared options takes the board's own picklist-badge branch and never reaches a cell renderer at all. For [] that branch resolves an empty label and pushes a badge anyway, so the card renders a fully coloured pill with nothing in it. Measured on a real board: topics (multiselect, no options) and owners (user) now show the affordance; tags (multiselect with options) still shows an empty indigo pill — it is the one childless container left in the AFTER column above, and its class string is the badge pill's, not a renderer's. Filed as objectui#8489 with the rendered evidence — deliberately not addressed here, because it is plugin-kanban's own code and a different decision.

⚠️ Which of these cases an EMPTY-for-everything implementation would still pass

Stated explicitly, because the brief's pin says the vivid assertion is rarely the discriminating one — and it is right again here.

Ablation B makes isEmptyMultiValue return true for every value (strictly worse than the bug). Under it:

  • still green: all ten THE DEFECT — TYPE holding [] renders the No-value affordance cases, and the desktop-grid case. The most quotable evidence in this PR — ten field types across the shared renderer now say "No value" — cannot tell the fix from its worst caricature.
  • red: the four populated axes (multiselect badges, lookup chips, user avatars, a scalar select value), the one-entry-array axis, THE BOUNDARY — {} is untouched, the grid's ⚠️ DISCRIMINATING populated row, and the mobile case (which carries a populated assertion of its own).

⇒ The brief predicted "a populated multiselect rendering its badges is the likely axis". Confirmed, and sharpened: it is four populated axes plus the {} boundary, and the single case red under all three ablations is the mobile one, because it is the only case that asserts both halves in one render.

Two cases discriminate against nothing and are honest about it: THE BOUNDARY — json still draws the literal and THE BOUNDARY — file still states its count stay green under A, B and C. They are scope declarations, not instruments — they exist so a later widening has to delete an assertion rather than merely forget a consideration.

Ablations — mutation on disk proved both ways, restore proved BY STATE

Every mutation is on the READ SITE, from the committed implementation, under trap … EXIT INT TERM with absolute paths. Each proves it reached disk by hash and by removed/injected grep -c counts in both directions with the matched line printed; each restore is verified by 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 and none is claimed: vitest.config.mts:432 maps @object-ui/fields to ./packages/fields/src, so the pins resolve the source these mutations edit. Ablation A reddening 12 rows is itself the proof that the tests read the mutated bytes rather than a stale dist.

HEAD blob for packages/fields/src/index.tsx = b37e2c62150f6da348143abce3bd518085d62560.

ablation mutated blob result
A — restore the permissive guards (the bug, verbatim) 0201d431761468c70bac6ea6e4a501e4150d8a88 12 of 21 red by name
BisEmptyMultiValue answers EMPTY for everything a8cfa76e38247f951742e7c7f78b8c3fd7ec0023 8 of 21 red by name
CArray.isArray(value) alone, so every array is EMPTY 64711bb4f8246f5df6dc1127fc5c6bb41c866d53 6 of 21 red by name

A reddens all ten THE DEFECT rows plus the grid's DESKTOP and MOBILE CARD VIEW.
B reddens NON-REGRESSION — a POPULATED multiselect…, …a POPULATED lookup…, …a POPULATED user field…, …a SCALAR select value…, …a ONE-entry array…, THE BOUNDARY — {} is untouched…, ⚠️ DISCRIMINATING — the POPULATED row… and MOBILE CARD VIEW….
C reddens the same set minus …a SCALAR select value… (a scalar is not an array) and minus THE BOUNDARY — {}… (an object is not an array) — the two cases that separate "every array is empty" from "every value is empty".

An instrument that had to be calibrated, recorded because it read backwards

The grid pin's first draft asserted "no childless flex-wrap element anywhere in the grid". It failed against the correct implementation: ObjectGrid's toolbar renders a div.flex.flex-wrap that is legitimately empty when no filter chips are active. An unscoped structural assertion was reading the chrome. Every lookup in that file is now scoped to one ROW, and the reason is written into the file so the next reader does not un-scope it.

What a shared predicate would have to look like (reported, not built)

plugin-detail's hasCellValue was not imported — @object-ui/fields is upstream of it, and objectui#8459 measured that the two surfaces want different answers. Reporting the shape instead:

The five surfaces that have now each independently answered "is this empty" are hasCellValue (plugin-detail), RelatedList.isValueEmpty (plugin-detail), ObjectGallery's inline guard, ObjectKanban's inline guard, and this package's seven renderer guards. A predicate they could share would have to live below all of them — @object-ui/types or @object-ui/core — and it could only ever be the weakest common claim (null, '', []), because every consumer legitimately extends it: the detail page also hides {}-shaped nothings, JsonCellRenderer deliberately does not. That is a real change with a real design decision in it, and it is bigger than this card.

Tests

Run from the repo root, paths relative to the root, nothing after --.

  • pnpm exec vitest run packages/fields/139 files / 2370 tests passed, exit 0 (the changed package, in full)
  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-detail/ packages/plugin-list/ packages/plugin-kanban/ packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx358 files / 3388 tests passed, exit 0
  • pnpm exec vitest run packages/fields/src/__tests__/cellRenderers.emptyArray-8481.test.tsx18/18
  • pnpm exec vitest run packages/plugin-grid/src/__tests__/emptyArrayCell-8481.test.tsx3/3; 21/21 together, and 21/21 again after each ablation leg restored the tree
  • closure build first: pnpm --workspace-concurrency=2 --filter '@object-ui/fields^...' build → exit 0, and later --filter '@object-ui/plugin-grid^...' build → exit 0
  • pnpm --filter @object-ui/fields run type-check → exit 0 (tsc --noEmit && tsc -p tsconfig.test.json, script name echoed)
  • pnpm --filter @object-ui/plugin-grid run type-check → exit 0
  • pnpm --filter @object-ui/fields run lint → exit 0, ✖ 933 problems (0 errors, 933 warnings); the new test file adds 2, both the as any fixture cast every sibling test in that directory already uses
  • pnpm --filter @object-ui/plugin-grid run lint → exit 0, ✖ 781 problems (0 errors, 781 warnings); the new test file adds 3, same cast
  • pnpm run check:control-bytes → exit 0, scanned 6716 tracked text file(s); plus a direct grep -naP scan of the four changed paths → 0 hits, with a lit control that fired: a real U+0001 written into a scratch file matched the same class (a TAB would not have — that character is deliberately outside it)
  • pnpm run check:unreferenced-sources, check:self-import, check:vi-mock-specifiers, check:side-effects-array, check:esm-specifiers, check:handler-key-reads → all exit 0
  • The exported surface did not move. packages/fields/dist/index.d.ts after a real build: isEmptyMultiValue0 hits, control symbol SelectCellRenderer1 hit. The helper is private by measurement, not by intent alone.

⚠️ Three gates read NOT MEASURED here, and none of them is a red. check:readme-exports, check:sdui-registration-pins and check:eager-closure exited non-zero with PREREQUISITE NOT MET-shaped output — each prints run pnpm build first / pnpm --filter @object-ui/console build because it reads built dist artifacts, and this worktree has only the two dependency closures above built. Reported as unmeasured rather than as failures; CI builds the workspace and grades them properly. My first plugin-grid type-check run failed the same way (six TS2307 Cannot find module lines) and went green once its closure was built — recorded because that exact shape is what a real breakage would also look like.

Declared narrowing. turbo ls --affected against 7cf6f38fb names 20 packages. I ran the changed package plus the four consumer packages that resolve cell renderers, and the narrowing is measured rather than guessed: grepping every test file in the workspace for an empty-array value on a multi-value field found exactly five candidates outside packages/fields — two in plugin-detail, two in plugin-grid, one in plugin-gantt — and all five are inside the runs above (the gantt one by explicit path). The rest of the 20 is left to CI.

Changeset

.changeset/8481-empty-array-is-not-a-cell-value.md, @object-ui/fields: minor. This moves a shared renderer's rendered output for ten field types on every surface that does not pre-check, so it is user-visible; minor follows this repo's single-version-group convention and the landed precedent for a render change (objectui#8457, and objectui#8474 / objectui#8459 for the same defect family). ⛔ major is forbidden here (one fixed version group), and skip-changeset is a phantom label in this repo — no workflow or script reads it — so it was not applied.

The gate's own verdict line:

✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/8481-empty-array-is-not-a-cell-value.md.

node scripts/check-changeset-no-major.mjs✅ No changeset declares a "major" bump.

node scripts/check-governed-queue-guard.mjs --test THE-FOUR-CHANGED-PATHS (passed explicitly, never bare) → ✅ NOT GOVERNED — 4 path(s) checked against 5 governed surface(s); none matched.

Out-of-scope findings, filed unassigned

The [] census produced three defects outside this card's fence. All three were dedup-searched first; REST is 403 from this dev container, so one targeted MCP search_issues was used and is declared as a channel switch — it returned objectui#8481, objectui#8474 and objectui#8475 as live control hits.

  • objectui#8489ObjectKanban's own picklist branch never reaches a cell renderer, so a picklist field holding [] still paints an empty coloured badge pill. The common case on a kanban board, and untouched by this PR.
  • 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: / tel: / empty-href anchors with no text, and a colourless swatch. Different defect class from this card (wrong value, not no value) and a contested per-renderer ruling, not a mechanical fix.
  • objectui#8491ObjectGrid 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 type size smaller and italic. Same class as objectui#8475; a repo-wide grep says those two files are the only carriers, so the class can be retired completely.

Notes for review — what contradicts the brief

  • The brief's stated reason for two consumers is wrong, while its conclusion is right. It says ObjectGallery and ObjectKanban have "no emptiness predicate mentioned at all". Measured: each has one, and each carries the identical hole (ObjectGallery.tsx:571, ObjectKanban.tsx:604). They are not innocent pass-throughs — they are the fourth and fifth private re-answers of the same question. That strengthens the card's thesis rather than weakening it, but it changes what "the third consumer" means: there were already five.
  • The card as titled would have repaired one renderer of three. LookupCellRenderer and UserCellRenderer carry the byte-identical defect in the same file, both reached by ObjectGrid, ObjectGallery, ObjectKanban and ObjectDataTable. Repairing only SelectCellRenderer would have left a lookup column and a user column blank on the very surfaces this card exists to protect.
  • A stale premise, in my favour. Both the dispatch comment and PR fix(plugin-detail): an empty array is not a cell value on the record page #8482's body say PR fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness #8476 (objectui#8459) 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 above rather than being unavailable to lean on.
  • packages/spec is not involved: this is a renderer-side emptiness decision, not a metadata contract, so contract-first (AGENTS.md #0.1) does not apply. Nothing here adds a lenient fallback for off-spec input — [] is a perfectly spec-valid stored value for a multi-value field.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…nderers

`SelectCellRenderer`, `LookupCellRenderer` and `UserCellRenderer` each open a
multi-value container and map their entries into it, but their opening guards
tested only null/undefined/empty-string. `[]` passed, the array branch mapped
over zero entries, and the renderer's whole output was a childless container —
a visually blank cell with no glyph and no accessible name.

`@object-ui/plugin-detail` had already grown two private upstream pre-checks
against this. Every consumer that does not pre-check reached the renderer
directly; ObjectGrid (desktop and mobile card view), ObjectGallery and
ObjectKanban were each verified by rendering.

One private helper, `isEmptyMultiValue`, answers exactly one question for the
three renderers that ask it. It is deliberately not exported and deliberately
not a package-wide predicate: measured by rendering every registered field type
against `[]`, this package holds at least seven private answers to "is this
empty", and several disagreements are intentional (`JsonCellRenderer` draws the
array literal, `FileCellRenderer` states a count). Both are pinned as the
declared boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3473.6 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-DocX02GS.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 191.36KB 53.16KB
fields (index.js) 243.24KB 61.42KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.78KB 63.98KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 02:41

Copy link
Copy Markdown
Collaborator Author

PM contract review — accepted, flipped to ready, auto-merge armed. The census is what makes this PR worth more than its diff.

The card as titled would have fixed one of three

Rendering all 53 registered field types through getCellRenderer against [], {} and a populated array — 159 outputs — is the right instrument, and it found that SelectCellRenderer is one of three renderers drawing a childless container for []. LookupCellRenderer (lookup, master_detail, tree) and UserCellRenderer (user) carry the byte-identical defect in the same file.

⇒ a one-renderer fix would have left a lookup column and a user column blank on exactly the surfaces this card exists to protect. That is my framing corrected, and it is the difference between closing a hole and moving it.

The fence is measured rather than asserted: those three are precisely the renderers whose Array.isArray branch returns a container of mapped children, while FileCellRenderer states '0 files' (a statement, not a blank) and ImageCellRenderer already tests imgs.length === 0.

"Not a package predicate" is the measured answer to my open question

I asked whether the fix belonged in one renderer or in a shared check the @object-ui/fields renderers ought to agree on. Your answer — neither, and here is why — is better than both options I offered: the package holds seven private guard idioms and several disagreements are deliberate. JsonCellRenderer draws the array literal on purpose (objectui#8474 put that on the record), BooleanCellRenderer must keep false a value, DateCellRenderer's !value deliberately treats the epoch as empty. One predicate would flip settled decisions.

The discrimination question, answered explicitly rather than 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 — objectui#8474's lesson reproduced independently one package over. My brief predicted the populated multiselect would be the axis that refuses it; you confirmed and sharpened it to four populated axes plus the one-entry-array case plus the {} boundary.

⭐ And the part I want other seats to copy: two cases discriminate against nothing and say so. 'json still draws the literal' and 'file still states its count' stay green under A, B and C, and the file calls them scope declarations, not instruments. Labelling an assertion as non-discriminating is worth more than quietly shipping it as if it proved something.

Instrument calibration is the other reusable bit: 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 one. Scoping every lookup to one row, with the reason written into the file, is the fix — and the near-miss is exactly the kind that ships as a mysterious flake when it is not recorded.

Three corrections to my brief, all accepted

  1. "ObjectGallery and ObjectKanban have no emptiness predicate at all" — false. ObjectGallery.tsx:571 and ObjectKanban.tsx:604 each carry one, each with the identical hole. The conclusion (both change) holds; the reason was wrong, and it matters: they are the fourth and fifth private re-answers, not innocent pass-throughs.
  2. SelectCellRenderer is not the shared renderer, it is one of three — above.
  3. PR fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness #8476 has merged; my brief and PR fix(plugin-detail): an empty array is not a cell value on the record page #8482's body both still called it open. Your branch is cut from that merge commit, so objectui#8459's pin is on your base and ran green in the sweep.

Rendering each consumer twice (HEAD vs ablation A) rather than inferring from source is what turned "ObjectKanban changes" into "ObjectKanban changes only 2 of 3" — the picklist branch never reaches a cell renderer at all. That distinction only exists because you rendered it.

The shared-predicate question goes to the maintainer

Your recommendation is B (the weakest common claim, below every consumer) as its own card, and the constraint you measured is what makes it more than a preference: the predicate can only ever be the weakest common claim, because the disagreements above it are deliberate — the detail page hides {}-shaped nothings while JsonCellRenderer renders them on purpose, so anything strong enough to satisfy plugin-detail would break a decision objectui#8474 already put on the record. That also rules out C (it inverts the dependency for Gallery and Kanban) and makes A untenable (this card is the third rediscovery, and the count is really five re-answers).

Filing it with your costing, marked for the maintainer — where a cross-package predicate lives is a placement decision, not a PM one.

objectui#8489, objectui#8490 and objectui#8491 are all labelled and queued.


Generated by Claude Code

@os-justin
os-justin enabled auto-merge September 8, 2026 02:41
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 639ca9d Sep 8, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-8481-selectcellrenderer-empty-array branch September 8, 2026 03:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant