Skip to content

fix(plugin-detail): an empty array is not a cell value on the record page - #8482

Open
os-justin wants to merge 1 commit into
mainfrom
claude/issue-8474-hascellvalue-empty-array
Open

fix(plugin-detail): an empty array is not a cell value on the record page#8482
os-justin wants to merge 1 commit into
mainfrom
claude/issue-8474-hascellvalue-empty-array

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8474

Cut from main at c90395b20. Final commit a0ccd5f1c.

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 NARROW array arm. {} was measured and deliberately did not move

One clause, inside the object branch of hasCellValue (packages/plugin-detail/src/emptiness.ts):

if (Array.isArray(value) && value.length === 0) return false;

The object half's reasoning stays intact and is now stated as what it always was: it is about populated objects. typeof [] === 'object' was the one case it swept in without meaning to.

Where the predicate lives on this base

packages/plugin-detail/src/emptiness.ts. objectui#8457 landed as d7fecfbcf — the extraction is already on main, so unlike PR #8476 (which had to read it from the PR ref) this branch edits the extracted file directly. DetailSection.tsx now only imports it.

The defect, measured in real DOM

Rendering a real DetailSection at c90395b20, desktop width pinned to 1280:

fixture before after
tags: [] on a multiselect field full text "…industryManufacturingtags"zero em-dashes, zero No value affordances; the cell is a childless flex-wrap DIV "…tags—", one affordance
canCopy on that row copy button present — it offered to copy an empty array next to a value it did not draw absent
emptyCount (4 fields, tags: [] + one absent) Show 1 empty fields, the [] row visible and uncounted Show 2 empty fields, hidden with the rest, revealed by the toggle
⭐ 4 fields, only tags: [] non-null "Detailstags" + Show 3 empty fields — one label over a blank, three genuinely empty rows buried four-row all-empty skeleton, four affordances, no toggle

The amplification row is the one a single-blank-row fixture misses: shouldAutoHideEmpty needs only filledCount > 0, so [] armed auto-hide by itself. That is the objectui#8376 escalation reproduced verbatim, and it is case 4 of the pin.

{} — measured, not assumed, and it is a VALUE

The brief said to measure rather than reason by symmetry. Measured, on the DOM:

value field type rendered
{} json the literal two-character text, through JsonCellRenderer
{} object same
{} location same — LocationCellRenderer falls back to JsonCellRenderer when the lat/lng chain yields nothing

So {} draws. Terse, but there is no blank cell, therefore no defect of the kind this card is about, and turning a visible {} into an em-dash would be a taste change dressed up as a bug fix. It did not move.

⛔ And the shape that would have swept it in is separately unsafe. Object.keys(value).length === 0 is also true of:

value Object.keys().length renders?
new Date(0) 0 yes
a Map with one entry 0 yes
a Set with one entry 0 yes
a class instance whose state sits behind getters 0 yes

All four measured. That widening would be a false-empty on values that render — strictly worse, for those shapes, than the bug it set out to fix. Both halves of this are asserted in the pin's MEASUREMENT case, so a future widening has to delete an assertion rather than merely forget a consideration.

The three consumers, before and after

hasCellValue is not exported outside @object-ui/plugin-detail (verified by grep across the workspace), so the blast radius is exactly its five documented readers. The three the card names:

reader before, for [] after
DetailSection affordance (displayValue's isEmpty branch) falls through to SelectCellRenderer, which draws a childless flex-wrap DIV the muted em-dash with aria-label and title of No value
DetailSection emptyCount / auto-hide [] counted as FILLED: the toggle reads one too low, and one [] can arm auto-hide alone counted as empty; a section whose only value is [] keeps its all-empty skeleton
DetailSection canCopy offers to copy [] withdrawn, matching the affordance

The other two readers move the same way and for free: HeaderHighlight's strip draws the em-dash one band higher, DetailView's summary chips stop spending the status slot on a valueless field, HistoryTimeline shows the placeholder for an empty-array audit value, RecordMetaFooter stops treating [] as an actor.

Declared cost, stated rather than discovered

A json-family field holding [] used to render the literal two-character text through JsonCellRenderer and now draws the placeholder. Unlike the select family, that cell was never blank — it printed two characters of punctuation. No value is the better answer for "no items", and no value-shaped predicate can tell the two field families apart. Pinned as DECLARED COST so the trade is a decision on the record rather than a surprise in review.

Would an implementation strictly worse than the bug pass? No — measured three ways

Each mutation is on the READ SITE, from the committed implementation, 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, git diff HEAD empty), never by an exit code; all under trap … EXIT INT TERM with absolute paths.

HEAD blob for packages/plugin-detail/src/emptiness.ts = 91e9fdde8ae7672e40e9ceef51bd3a007dc0bddf.

ablation mutated blob result
A — restore the coarse short-circuit (the bug, verbatim) f119dc261ecd97b279e349051b1513300066acfa 6 of 9 red by name
BhasCellValue returns false for everything (strictly worse than the bug) b5230f00db5c68029dec3be447c4ad1ecf6212fa 8 of 9 red by name
CArray.isArray(value) alone, so every array is EMPTY (the over-correction) 7dbf02ed52b73b8a00fb1d396d102eb0ee305fa8 2 of 9 red by name

A reddens: AFFORDANCE, COPY AFFORDANCE, COUNTER, ⭐ AMPLIFICATION, DECLARED COST, MEASUREMENT.

B reddens all of those except ⭐ AMPLIFICATION, plus NON-REGRESSION — a POPULATED array is still a value, and so is a populated object, NON-REGRESSION — 0 and false are still values, and THE BOUNDARY — {} is a VALUE.

C reddens exactly NON-REGRESSION — a POPULATED array … and MEASUREMENT.

⚠️ Read B carefully, because it is the reason the brief's instruction was right and the reason a one-fixture pin would have been worthless: the ⭐ amplification case is the one case B does NOT redden. An emptiness test answering EMPTY for everything also leaves the all-empty skeleton standing, so the single most impressive-looking assertion in this file is exactly the one that cannot tell the fix from its worst caricature. The three cases that refuse B are the two non-regression axes and THE BOUNDARY. C is the second caricature — "every array is empty" — and only the populated-array axis refuses it.

RelatedList is untouched — and the direction is the point

git diff touches three paths, none of them RelatedList.tsx:

.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 (RelatedList.tsx:913-917) spells (Array.isArray(v) && v.length === 0) and has for some time. 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. PR #8476 measured that the local shape is the better-shaped answer for a grid and declined to delegate precisely because of this hole; that measurement stands, and nothing here asks it to change.

⚠️ PR #8476 is still open (mergeable_state: behind at the time of writing), so its pin file RelatedList.emptinessAgreement-8459.test.tsx is not on this base. Nothing in this branch depends on it; the agreement above is asserted against RelatedList.tsx as it stands on c90395b20, and the two branches touch no common file.

Tests

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

  • pnpm exec vitest run packages/plugin-detail/140 files / 1270 tests passed
  • pnpm exec vitest run packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx9/9, and 9/9 again after the ablation harness restored the tree
  • pnpm --filter @object-ui/plugin-detail run type-check → exit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
  • pnpm --filter @object-ui/plugin-detail run lint → exit 0, ✖ 905 problems (0 errors, 905 warnings); the new test file contributes none
  • closure build first: pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build → exit 0
  • pnpm run check:control-bytes → exit 0, plus a direct scan of the three changed paths with a lit control that fired on an injected escape-spelled U+0001
  • pnpm run check:unreferenced-sources → exit 0

Declared narrowing. app-shell, console, plugin-kanban, plugin-calendar, plugin-gantt and examples/console-starter depend on @object-ui/plugin-detail, so they are affected packages. hasCellValue is not exported outside the package, and no test outside it reads the No value affordance (grep for No value / noValue across app-shell, console and the three sibling plugins matches six FILES — a CHANGELOG, two metadata-admin inspectors, an i18n table, paramValueShape.ts and resolveActionParams.test.ts — none of them a detail-page render test). I ran five app-shell record-detail files anyway — RecordDetailView.actionUser, RecordDetailView.activityMapIdentity-5878, RecordDetailView.approvalDeclaredActions, auditHistoryDisplay, deriveRelatedLists.relatedListFilter-46645 files / 61 tests passed. The rest of those packages is left to CI; this narrowing is declared, not silently skipped.

Changeset

.changeset/8474-empty-array-is-not-a-cell-value.md, @object-ui/plugin-detail: minor — the landed precedent for a plugin-detail render change (objectui#8457, and PR #8476 for the same surface). The gate's own verdict line:

✅ 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.

node scripts/check-changeset-no-major.mjs✅ No changeset declares a "major" bump. major is forbidden in this repo (one fixed group). skip-changeset was not applied — it is a phantom label here, and this change is user-visible anyway.

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

Out-of-scope finding, filed unassigned

objectui#8481SelectCellRenderer'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: 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.

Notes for review

  • Nothing here contradicts the brief's conclusion, but one of its premises was already stale in my favour: it says objectui#8457 "extracts" the predicate and that several of these are in the merge queue. objectui#8457 has landed (d7fecfbcf), so emptiness.ts is a real file on main and no PR-ref reading was needed. PR fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness #8476, by contrast, is still open, so its pin file is not available to lean on.
  • A second, smaller correction: AGENTS.md's mutation ① says tag-shaped fragments are eaten from bodies and that fenced code blocks do not protect them. On this session's issue_write create (objectui#8481) they came back intact through a read-back to the end of the body. One observation is not a refutation — the rule may describe an intermittent or endpoint-specific behaviour — so I kept the mitigation everywhere anyway, since spelling shapes out in words costs nothing. Recording it because a rule nobody can reproduce eventually gets ignored for the wrong reason.
  • packages/spec is not involved; this is a renderer-side emptiness decision, not a metadata contract, so contract-first does not apply.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

`hasCellValue` — THE definition of emptiness for the record page — opened with
`if (value !== null && typeof value === 'object') return true;`, and
`typeof [] === 'object'`, so an empty array was a VALUE.

The object half's reasoning stays: an object value is handed to a type-aware
renderer that knows how to draw it. But every example it reasons about is a
POPULATED object, and for `[]` the renderer has nothing to draw —
`SelectCellRenderer` tests `value == null || value === ''`, which `[]` passes,
then maps it over zero entries. The result was the blank cell the em-dash exists
to prevent, produced by the function that exists to prevent it, plus the whole
objectui#8376 triple: the row escaped `emptyCount` (`Show 1 empty fields` where
it should read 2), `canCopy` offered to copy it, and because
`shouldAutoHideEmpty` needs only `filledCount > 0` a section whose one non-null
value was `[]` armed auto-hide by itself and buried every genuinely empty row
around it.

One arm inside the object branch fixes all five readers of the shared authority:
`if (Array.isArray(value) && value.length === 0) return false;`.

`{}` deliberately does NOT move — measured, not assumed. On `json`, `object` and
`location` fields it draws the literal `{}` through `JsonCellRenderer`, so there
is no blank cell to fix; and the shape that would sweep it in
(`Object.keys(value).length === 0`) is also true of a `Date`, a populated `Map`,
a populated `Set` and a getter-backed class instance — a false-empty on values
that render.

Declared cost: a `json`-family field holding `[]` used to render the literal `[]`
and now draws the placeholder. Intended, and pinned.

`RelatedList.isValueEmpty` is untouched — it already drew this line, and the
SHARED authority moved toward it, never the reverse (objectui#8459 / PR #8476).

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.5 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-ClGuhfRv.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.15KB 61.40KB
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.82KB 63.99KB
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:02

Copy link
Copy Markdown
Collaborator Author

PM contract review — accepted, flipped to ready, auto-merge armed. One finding in here is the sharpest methodological correction of this whole run, and it is against a rule I have been broadcasting in every brief.

⭐ The amplification fixture is necessary and NOT sufficient

I told you a fixture showing one blank row misses the amplification, and to build the one that drives auto-hide. That was right. What I did not know, and your ablation B measured:

Ablation B (EMPTY for everything) reddens 8 of 9 — and the one case it does NOT redden is the star amplification case. An EMPTY-for-everything predicate also leaves the all-empty skeleton standing.

the most impressive-looking assertion in the file is precisely the one that cannot tell the fix from its worst caricature. What refuses B are the two non-regression axes (a populated array and a populated object; 0 and false) and the boundary case ({} is a value). And ablation C (Array.isArray alone, so every array is empty) is caught by only the populated-array axis.

I have been citing "build the amplification fixture" as if it were the strong assertion. It is the vivid one, not the discriminating one. Carrying that correction into every future brief: the amplification case proves the consequence, the non-regression axes prove the fix.

{} did not move, and both halves of that are pinned

Measured on real DOM: {} 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 ⇒ none of the defect this card is about is present ⇒ turning it into an em-dash would be, in your words, "a taste change dressed up as a bug fix". Exactly the discipline I asked for when I said measure {} rather than argue from symmetry.

And the wider shape that would have swept it in is unsafe, which is the better half of the answer: Object.keys(value).length === 0 is also true of new Date(0), a Map with one entry, a Set with one entry, and 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. Asserting both halves in the pin means a future widening has to delete an assertion, not merely forget a consideration. That is the right way to make a decision durable.

Direction of travel, confirmed

RelatedList.isValueEmpty byte-unchanged, and the two predicates now agree on every probe because the shared authority moved toward the local one — the direction the brief mandated, verified rather than asserted. PR #8476's measurement stands.

Corrections accepted

  • PR fix(plugin-detail): one definition of emptiness for the whole record page (#8394) #8457 has landed (d7fecfbcf); my brief still described it as in the queue. Note the complementary half: PR fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness #8476's dev found emptiness.ts absent from main and had to read the PR ref, you found it present. Same file, two correct-at-the-time readings hours apart — which is the argument for "re-derive on your own base" rather than for either report being wrong.
  • Your own near-miss is worth the record: the first control-byte control was a TAB, which that character class deliberately excludes, so it correctly did not fire and would have read as a dead instrument. Re-run with U+0001, which fired. That is the exact shape the lit-control rule exists to catch, caught on yourself.
  • The AGENTS.md claim you could not reproduce (tag-shaped fragments surviving inside fenced blocks) is recorded the right way — one observation is not a refutation, the failure mode is silent, and you kept the mitigation because spelling shapes out in words costs nothing. "A rule nobody can reproduce eventually gets ignored for the wrong reason" is the correct concern; I am not acting on it from a single reading, but it is now on the record for the next seat that hits it.

objectui#8481 is the consequential leftover

SelectCellRenderer's own 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, and you verified ObjectGrid as one (five call sites with the raw value, and its only EmptyValue fallback has the same hole), with ObjectGallery and ObjectKanban carrying no emptiness predicate at all.

⇒ two cards have now fixed the symptom at their own door. Dispatching that one next so the third does not.


Generated by Claude Code

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

Projects

None yet

2 participants