Skip to content

fix(fields): an empty array is not a cell value, and eight renderers fabricated one - #8586

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8490-fields-empty-array-fabrication
Sep 8, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-8490-fields-empty-array-fabrication

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8490

What this changes

Eight cell renderers in @object-ui/fields (twelve field types) drew a value the record does not hold when the stored value was [], and a ninth drew a placeholder that was not the shared one. They now render the shared EmptyValue affordance — the muted em-dash carrying a No value accessible name — exactly as they already did for null. One consumer pin in plugin-grid covers both grid widths. Tag-shaped fragments are spelled in words throughout this body (the GitHub sanitizer eats them, even inside backticks).

The census was re-derived first — the card's eight are still eight

Measured by rendering every registered field type (53 keys, extracted from getCellRenderer's map at test time so a new key cannot be missed) against [], {}, '' and null on e411c3e58, the merge base. The three shas in the brief were each verified as ancestors of that base (merge-base --is-ancestor exit 0, which is the self-proving direction; not shallow). 639ca9dc0 (the objectui#8481 landing) moved none of the eight: all twelve types in the card's table still fabricated, and the ninth date row still drew formatDate's private dash. So the PR keeps the card's shape.

field types renderer rendered for [] on e411c3e58 now
boolean, toggle BooleanCellRenderer a CHECKED disabled checkbox, aria-checked true; a completion-named field drew the green "Completed" indicator EmptyValue
number, slider, rating NumberCellRenderer the digit 0 EmptyValue
currency CurrencyCellRenderer the digit 0 EmptyValue
percent, progress PercentCellRenderer a 0% bar with a progressbar role, aria-valuenow 0, and the text 0% EmptyValue
email EmailCellRenderer a live anchor with href of mailto: and no text, plus a "Copy email" button EmptyValue
url UrlCellRenderer a live _blank anchor with an empty href EmptyValue
phone PhoneCellRenderer a live anchor with href of tel: and a phone icon, plus a copy button EmptyValue
color ColorSwatchCellRenderer a bordered swatch box with no background colour beside an empty hex span EmptyValue
date (adjacent ninth) DateCellRenderer a SPAN classed tabular-nums with an empty title holding a bare em-dash EmptyValue

Re-running the same census on the fixed tree and diffing cell by cell: 19 of 212 cells moved, 193 unchanged. The 19 are the 13 types above on [], plus the six number-family types on '' (the declared sweep below). {} moved nowhere.

One correction to the record while measuring: objectui#8481's narrative said plugin-detail was already protected by hasCellValue. It is not — hasCellValue returns true for every non-null object, arrays included (it delegates object values to the cell renderers by design), so DetailSection reached these renderers with [] too and the checked checkbox was on the record page as well as the grid. Only RelatedList's isValueEmpty pre-checks arrays. The fix being in the shared renderer covers both.

The ruling is per renderer, not one predicate

The card asked for this distinction explicitly. Every row lands on EmptyValue, but for different reasons and with different sweep widths — and No value is a true statement at each site, because in each case the record holds no value of the field's type:

  • boolean / toggle — [] is not a boolean and holds no entries, so the column holds no value, not false. Only [] moves (isEmptyMultiValue, the objectui#8481 helper, now with a fourth caller). A real false is still an unchecked box; an active-named field holding a real false still draws its "Off" badge. Scalar truthiness coercions (0, '', 'false', {}) are untouched by this guard — that is a separate decision, recorded as objectui#8582.
  • number / slider / rating / currency / percent / progress — the 0 was Number(''), a JavaScript coercion artefact, not a stored zero. The guard is therefore on the coerced text (isBlankCellText: null, or a string that trims to nothing), which deliberately also sweeps a stored '' and whitespace — the identical fabrication one input shape over. Declared and pinned. A real stored 0 still prints 0 / 0% with its bar.
  • email / url / phone — nothing to link to means no anchor and no copy button in the DOM. [''] has no address either; ['ada@example.com'] still links (the test is on the coerced text, not on Array.isArray).
  • color — no colour string means no swatch.
  • date — the coerced-empty branch reaches the shared affordance instead of formatDate's private dash. An unparsable string still takes that dash today; that is a decision (DateTimeCellRenderer already answers it with the affordance) and is filed as objectui#8581.

Evidence — every leg was run, none predicted

packages/fields/src/__tests__/cellRenderers.fabricatedValue-8490.test.tsx — 29 cases in three blocks: THE DEFECT (15 [] cases, each asserting the fabricated artefact's absence first, then the affordance), POPULATED (10 load-bearing cases), THE BOUNDARY (4). Each [] case is ordered so that its failure on the unfixed tree and its failure under a dead harness land on different sentences.

Three ablation legs, each run against the committed implementation (index.tsx blob b4d6dfe48), each mutation proven on disk in both directions before the run and restored by state afterwards (git diff HEAD empty; git hash-object of the path equal to the HEAD blob; trap on EXIT INT TERM with absolute paths). No dist preflight was needed and that is declared: the root vitest config aliases every @object-ui/* specifier to src, so both the mutation and the restore are live without a build.

leg mutation (on-disk proof) result failure sentences (distinct per leg)
A — red before base index.tsx swapped in (isBlankCellText count 0, base-only guard line count 3, 3338 lines) 17 fail / 12 pass boolean: a checked checkbox is a fabricated value for [], number: the digit 0 is a fabricated value for [], percent: a 0% progress bar is a fabricated value for [], email: a live anchor with an empty target is a fabricated affordance for [], color: a bordered swatch box with no colour is a fabricated value for [], date: the value span must not be drawn around nothing, plus the two '' / [''] boundary sentences. All 10 POPULATED cases pass on the base — they measure what the base already did right.
B — the caricature nine renderers return EmptyValue unconditionally (ABLATION-8490 marker count 9) 11 fail / 18 pass exactly the 10 POPULATED cases plus the boolean scalar-0 boundary, each on its own sentence: boolean: a real true must still draw its checkbox, boolean: a real false must still draw its checkbox, completed: a real true must still draw the indicator, active: a real false must still draw the Off badge, number: a real 0 must still print, email: a real address must still draw its anchor, email: a one-entry array still links to its one address, color: a real colour must still draw its swatch, date: a real date must still draw its value span. Survivors, declared as scope: the 15 [] cases and three boundary cases pass under the caricature by design — they cannot discriminate it, which is why the POPULATED block exists.
C — harness kill data-slot="empty-value" renamed in components/src/custom/empty.tsx (KILLED count 1, original 0) 17 fail / 12 pass every [] case on …: the shared EmptyValue affordance must be present (the date row on the em-dash must be the shared affordance, not a bare span…) — a different sentence from leg A on every row, proving the defect assertions do not navigate by the thing the caricature erases.

Harness-death signatures (Cannot read properties of null, Unable to find, TypeError, is not a function) counted in every leg's log: 0 in A, B and C — every failure is the discriminating assertion's own message.

Recorded for honesty: the first attempt at leg A was a null operation. The BASE file I wrote carried a BASE= prefix, git show failed on the malformed ref and wrote an empty index.tsx; all 29 cases failed with TypeError: resolveCellRendererType is not a function, and my on-disk gate only checked the deleted-text count (0) — the injected-text count was also 0. Discarded; redone with a gate on both counts and the line total. The restore-by-state check held on the null run too.

Consumer pin: packages/plugin-grid/src/__tests__/fabricatedValueCell-8490.test.tsx — desktop row with is_active: [] / amount: [] draws no checkbox and no 0 and carries two affordances; the populated row in the same grid still checks its box and prints its real 0; the sub-768px card view repeats both.

Verification

  • pnpm exec vitest run packages/fields/ — 140 files / 2399 tests passed (command-exit 0 under the shared verify lock).
  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-kanban/ — 149 files / 1250 tests passed.
  • pnpm exec vitest run packages/plugin-detail/ — 143 files / 1281 tests passed.
  • pnpm exec vitest run packages/plugin-dashboard/ packages/plugin-list/ plus, in the same run, the 30 test files elsewhere that name a fields cell renderer or EmptyValue (app-shell 24, plugin-gantt 3, plugin-view 1, plugin-report 1, console 1) — 201 files / 2020 tests passed. Local total: 633 files / 6950 tests, every run with command-exit 0 under the shared verify lock.
  • pnpm --filter @object-ui/fields run type-check — echoed tsc --noEmit && tsc -p tsconfig.test.json, exit 0, after building the dependency closure (pnpm --filter '@object-ui/fields^...' build). tsc -p tsconfig.test.json --showConfig lists the new test file (and the objectui#8481 control), so the test program covers it.
  • pnpm --filter @object-ui/fields lint and pnpm --filter @object-ui/plugin-grid lint — 0 errors. eslint's own --format json counts 221 files linted in fields and 152 in plugin-grid; the population is each package's eslint ., and the config is not type-aware, so this diff cannot move a verdict in any file it did not touch — the untouched packages' lint is invariant and left to CI's pnpm lint. Warnings are the repo's pre-existing 935 / 784 at warn level: 0 of index.tsx's 124 warning lines fall on the 78 lines this PR added (intersected against the -U0 diff), and the two new test files carry five no-explicit-any warnings on the same as any harness shapes the objectui#8481 tests use.
  • check:control-bytes OK (6770 files); check-changeset-presence OK; check-changeset-fixed OK; check-governed-queue-guard --test on the four paths: NOT GOVERNED; check:unreferenced-sources OK; check-type-check-coverage OK (45/46 via type-check, 42/42 test programs). pnpm check and the rest of lint.yml's repo-wide scans go to CI.
  • A Python zero-width / control-character scanner (with a lit control that must fire) over the three committed files read from git objects: 0 hits.

Declared narrowing. turbo ls --affected lists 20 packages. Run locally in full: fields and the read-mode cell surfaces (plugin-grid, plugin-kanban, plugin-detail, plugin-dashboard, plugin-list), plus the grep-selected files elsewhere. Sent to CI: app-shell (653 files), console (90), plugin-form (84 — it consumes field widgets, not the cell renderers this PR touches), and the rest of the affected list. Checkable reason: the diff changes only the bodies of nine cell renderers reached through getCellRenderer or direct import, and only for inputs whose coerced text is blank; the packages sent to CI reach those renderers only through the plugins run here.

Boundary and out of scope

  • {} is untouched everywhere (the objectui#8481 fence); json still draws the array literal and file still states 0 files (pinned again here as THE BOUNDARY).
  • Filed while measuring, none swept in: objectui#8580 (markdown / html / richtext paint a childless container for [] — the blank class, not this card's), objectui#8581 (DateCellRenderer's dash for an unparsable string), objectui#8582 (BooleanCellRenderer reads 'false' and {} as checked).
  • Changeset: patch — a correction of false output, not a capability. No README or guide documents the empty-value behaviour of these renderers, so no docs moved.

Session: session_01YBWFb5YgMU5dw8p2VKj16S (written in prose so it survives a later edit of this body).

🤖 Generated with Claude Code


Generated by Claude Code

…fabricated one

objectui#8481 repaired the shared read renderers whose output for `[]` was
blank. These are the other half of that census — the renderers whose output
for `[]` was not blank but WRONG, a value the record does not hold. Re-measured
by rendering every registered field type on e411c3e (after 639ca9d, which
moved none of them): `boolean`/`toggle` drew a CHECKED checkbox, the number /
currency / percent family printed a `0` (and a 0% progress bar), `email` /
`url` / `phone` wrapped nothing in a live `mailto:` / `tel:` / empty-href
anchor, `color` drew a swatch with no colour, and `date` drew `formatDate`'s
hand-rolled em-dash with no accessible name.

The ruling is per renderer, not one predicate. `[]` holds no boolean, so a
boolean column holding it holds NO value — not `false`; only `[]` moves there
(`isEmptyMultiValue`), a real `false` is still an unchecked box. The number
family's `0` was `Number('')`, a coercion artefact, so its guard is on the
coerced text (`isBlankCellText`) and deliberately also sweeps a stored `''`.
The link family draws no anchor with nothing to link to; `color` no swatch
without a colour string; `date` reaches the shared affordance instead of a
private dash. `{}` is untouched everywhere; `json` and `file` keep the
objectui#8481 fence. A consumer pin in plugin-grid covers both grid widths.

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) 3475.6 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-fV9qCNXj.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.93KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 192.72KB 53.55KB
fields (index.js) 243.54KB 61.49KB
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.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.46KB 63.90KB
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.30KB 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 13:35
@os-justin
os-justin enabled auto-merge September 8, 2026 13:35
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 2152962 Sep 8, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-8490-fields-empty-array-fabrication branch September 8, 2026 14:04
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): eight cell renderers FABRICATE a value for an empty array — a checked checkbox, a zero, a live empty mailto/tel link

1 participant