Skip to content

fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness - #8476

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8459-relatedlist-emptiness
Sep 8, 2026
Merged

fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness#8476
os-justin merged 1 commit into
mainfrom
claude/issue-8459-relatedlist-emptiness

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8459

Cut from main at a407bd654. Final commit 5e1dcddd9.

Verdict: CONVERGED — onto the local isValueEmpty, deliberately NOT onto hasCellValue

The two sites are the same question at two granularities, so "genuinely different questions" (the ConcurrentUpdateDialog outcome) does not apply here. pruneEmpty keeps a column when .some() cell is not empty — it is defined in terms of the cell test. Its promise to the reader ("a column you can see holds something") is only true while both spell "empty" the same way.

site asks verdict
isValueEmptypruneEmpty (RelatedList.tsx:913-917) "is every cell in this column empty?" converged — this is the authority for both
makeCell placeholder branch (RelatedList.tsx:963) "does this cell draw the placeholder?" converged onto the above

The old cell test (null | undefined) was a strict subset of isValueEmpty, so this is a pure widening: no cell that drew the em-dash before stops drawing it.

The defect, measured in real DOM (not the predicate)

The set difference isValueEmpty \ (null|undefined) is exactly {whitespace-only string, empty string, empty array} — and it maps one-to-one onto the observed blank cells. Rendering a real grid on a407bd654:

value pruneEmpty says cell rendered before after
null EMPTY
'' EMPTY (rescued downstream by TextCellRenderer, not by this component)
' ' EMPTY " " — visually blank
[] EMPTY "" — visually blank
0 FILLED 0 0
{latitude, longitude} FILLED 51.5000, -0.1200 unchanged

Both failure modes stack exactly as the card describes: a column survives because some other row has a value, and the blank row then paints nothing where the same function draws for null one branch above.

⚠️ This contradicts the brief: converging onto hasCellValue would have REGRESSED

hasCellValue opens if (value !== null && typeof value === 'object') return true; — and typeof [] === 'object', so it calls an empty array a VALUE. RelatedList.isValueEmpty calls it EMPTY, and for a grid that is the right answer: SelectCellRenderer tests value == null || value === '' (which [] passes) and then maps over zero options, drawing nothing.

Measured today: an all-[] column is correctly pruned. Delegating to the shared authority would have made it survive and render a column of blank cells — introducing the defect this card exists to remove.

I read PR #8457's extracted emptiness.ts as instructed: its predicate is byte-identical, so this holds after that PR lands too. Its docblock reasons about ['alpha','beta'] rendering as badges — a populated array — and never considers []. The scalar/object split is right; the object half is simply too coarse by one case, and isValueEmpty already draws the finer line.

The two agree on everything else: both trim scalars, both treat non-empty objects as values, both treat 0/false as values.

Filed as #8474: the same [] hole in the shared authority is a live defect on the record page — a DetailSection field holding [] renders "SNameWidgetTags", zero em-dashes, plus the full objectui#8376 triple (escapes emptyCount, canCopy offers to copy it).

The pin

packages/plugin-detail/src/__tests__/RelatedList.emptinessAgreement-8459.test.tsx — 6 cases, all asserting rendered outcome: real SchemaRenderer, real data-table, real th/td text. window.innerWidth is pinned to 1280 (objectui#8399) because RelatedList swaps the whole table for an object-gallery on mobile, which draws none of this.

Every negative carries a control that rendered by value — "the Note column is absent" is trivially true of a grid that rendered nothing, so the pruning case also asserts Product is present and Widget reached a cell.

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

ablation on-disk proof (HEAD blob → mutated blob) result
A — revert the fix (isValueEmpty(value)null|undefined) 6352a94681ff1624 2 red by name: A SURVIVING WHITESPACE-ONLY CELL DRAWS THE EM-DASH, A SURVIVING EMPTY-ARRAY CELL DRAWS THE EM-DASH
BisValueEmpty = () => true (EMPTY for everything) 6352a9463f699b31 6 of 6 red — it prunes every column, and both non-regression axes plus every by-value control catch it
C — the hasCellValue shape (drop the array clause, [] is a VALUE) 6352a946546226ed 1 red by name: A SURVIVING EMPTY-ARRAY CELL DRAWS THE EM-DASH

Ablation C is what makes "do not delegate to hasCellValue" a measurement rather than a claim. Each mutation proved it reached disk by hash and by removed/injected grep counts in both directions; each restore was verified by state (git hash-object equal to git rev-parse HEAD:PATH, git diff HEAD empty), never by an exit code, from a committed implementation, under trap … EXIT INT TERM with absolute paths.

The two non-regression axes that make B fail: 0 IS A VALUE (an all-zero column survives and renders 0) and AN OBJECT IS A VALUE (a location column renders 51.5000, -0.1200). The second is also the objectui#8376 / objectui#8394 axis — red for a wholesale delegation to the display-name authority.

Census — by mechanism, and there ARE more spellings

Instrument: seven mechanism sweeps over the whole file, not one grep for the known pattern — null/undefined comparison in both operand orders, '' comparison in both orders, .trim(), .length === 0 / Object.keys().length, Array.isArray guards, String() coercion guards, Boolean()/filter(Boolean). This is what catches the reversed-operand shape objectui#8394's own census missed.

It found two more value-presence spellings, and per-site they are genuinely different questions — reported, not converged:

  • RelatedList.tsx:685-687if (v == null) continue; if (typeof v === 'string' && v) ids.add(v). Loose == null plus a truthiness test that rejects '' but accepts ' '. Asks "is this a fetchable foreign key?"; its answer never reaches the reader as a rendered value. A whitespace id produces a doomed $filter, not a blank cell.
  • RelatedList.tsx:746val !== null && val !== undefined && String(val).toLowerCase().includes(lower), written in the present direction. Asks "does this value contribute searchable text?", over Object.values(row)all fields, including ones no column shows. Different domain and different contract (it decides which rows appear); its guard exists so String(null) does not become the searchable text "null".

Also worth the maintainer's eye, reported not fixed: when makeCell returns undefined (a column whose field has no resolvable type), the column gets no cell renderer and the table renders the raw value, so the two spellings still disagree on that path. Adding a cell where there deliberately is none is a different mechanism with a new verification surface, so it is left for triage rather than ridden in here.

Tests

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

  • pnpm exec vitest run packages/plugin-detail/138 files / 1239 tests passed
  • pnpm exec vitest run packages/plugin-detail/src/__tests__/RelatedList.emptinessAgreement-8459.test.tsx6/6
  • 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, ✖ 904 problems (0 errors, 904 warnings); the 3 warnings in the new test file are no-explicit-any, matching every sibling test in the package
  • closure build first: pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build → exit 0

Declared narrowing. app-shell consumes RelatedList (RecordDetailView, AssignedUsersSection), so it is an affected package. The full packages/app-shell/ suite exceeded the 10-minute foreground cap twice without producing output, so I ran the tests that actually mount a RelatedList surface instead — RecordDetailView.relatedListFilter-4664, RecordDetailView.relatedListInheritedSort-5795, richtextSurfaceParity, AssignedUsersSection, both RelatedRecordActionsBridge tests, deriveRelatedLists7 files / 64 tests passed. The rest of app-shell is left to CI; this narrowing is declared, not silently skipped.

Changeset

.changeset/8459-relatedlist-emptiness-agreement.md, @object-ui/plugin-detail: minor — matching the landed precedent for a plugin-detail render change (PR #8457). The gate's own verdict line:

✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/8459-relatedlist-emptiness-agreement.md.

skip-changeset was not applied — it is a phantom label in this repo, and this change is user-visible anyway. major is forbidden here (one fixed group).

node scripts/check-governed-queue-guard.mjs --test <the 3 changed paths> (paths passed explicitly, never bare) → ✅ NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.

Notes for review

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…lder share ONE definition of emptiness

`isValueEmpty` — the predicate behind `pruneEmpty`, which drops a column whose
every cell is empty — counts null, undefined, a string empty after trimming,
and an empty array. The placeholder branch of `makeCell`, which draws the muted
em-dash for one cell, tested null and undefined alone.

The two are not independent questions: `pruneEmpty` keeps a column when *some*
cell is not empty, so a column the reader can see has been promised it holds
something. When only one row was blank the column survived, and that row then
painted a visually blank cell — the exact UI the em-dash exists to prevent,
drawn by the same function that draws the em-dash for null one branch above.

The cell branch now asks `isValueEmpty`. The old test was a strict subset of it,
so no cell that drew the placeholder before stops drawing it.

Deliberately NOT delegated to DetailSection's `hasCellValue`: it calls every
non-null object a VALUE and `typeof [] === 'object'`, so it reads an empty array
as filled. The select renderer paints nothing for `[]`, so delegating would have
made an all-empty-array column survive and render blank cells — introducing the
defect being fixed.

Pinned end-to-end in DOM, with by-value controls on every negative and two
non-regression axes (`0` is a value; an object is a value) that are red for a
predicate answering EMPTY for everything.

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-B_H8pSAD.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.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 01:36

Copy link
Copy Markdown
Collaborator Author

PM contract review — accepted, flipped to ready, auto-merge armed. You reached my brief's conclusion by refuting its reason, which is the more useful outcome.

My warning was right for the wrong reason, and the right reason is sharper

I told you not to converge onto hasCellValue, on the grounds of the scalar/object split — "objects go to type-aware renderers that draw them fine". That reason does not cover the empty array, which has nothing to draw.

Measured: hasCellValue opens if (value !== null && typeof value === 'object') return true, and typeof [] === 'object'. So it calls an empty array a VALUE, while isValueEmpty calls it empty. An all-[] column is correctly pruned today; delegating would have made it survive and render blank cells — introducing the very defect this card exists to remove.

hasCellValue's blanket "every non-null object is a value" is too coarse by exactly one case, and RelatedList's local predicate is genuinely better-shaped for this surface. Ablation C makes that a measurement rather than an argument — which is the difference between a brief's advice and a repo's knowledge.

The convergence direction is argued, not assumed

Converging onto the local isValueEmpty rather than the shared authority is right for a reason worth keeping: pruneEmpty keeps a column when .some() cell is not empty, so it is defined in terms of the cell test. Its promise to the reader is only true while the two agree — they are the same question at two granularities, not two questions. And the old cell test being a strict subset makes the fix a pure widening: no cell that drew the em-dash before stops drawing it.

The census found two more spellings and judged them apart

:685-687 (lookup id harvesting) and :746 (the client text filter) are both genuinely different, with reasons that hold: the first never reaches the reader as a rendered value (a whitespace id yields a doomed filter, not a blank cell); the second reads Object.values(row)all fields, including ones no column shows — and decides which rows appear, with its guard existing so String(null) does not become the searchable text 'null'.

Seven mechanism sweeps rather than one grep, and sweep 1 in the reversed/present operand direction is what caught them — the exact shape objectui#8394's own census missed. That is the technique generalising, which is what I hoped for when I asked you to census by mechanism.

Two stale instructions of mine, correctly handled

packages/plugin-detail/src/emptiness.ts does not exist on main — PR #8457 has not landed, and both my card and my brief told you to read its docblock first. Reading the equivalent on main and the extracted file from refs/pull/8457/head, and reporting that the predicates are byte-identical so nothing turns on it, is exactly right. Likewise the merge-queue collision I warned about is not one: PR #8457 does not touch RelatedList.tsx.

Also noted: '' was already drawing the em-dash before this fix — but by downstream coincidence (TextCellRenderer independently tests it), not by decision. After this PR that answer is decided here instead of depending on each renderer re-implementing emptiness. Narrower than the card framed it, and better than the card framed it.

Your three findings

All labelled and queued. objectui#8474 is the consequential one and I am treating it as such: the shared hasCellValue calls an empty array a value, so the record page paints a blank row for [] — with the full objectui#8376 triple (escapes emptyCount, canCopy offers to copy it, and it can arm auto-hide on its own) — and it survives PR #8457 verbatim, since the extracted predicate is identical. That is a defect in the authority three landed cards converged onto, found by the one card that declined to converge onto it.

objectui#8475 (RelatedList's hand-rolled em-dash is the only one of the three with no accessible name) and objectui#8477 (a column with no resolvable field type gets no cell renderer, so pruning and the placeholder still disagree on that path, measured on this branch) are both correctly scoped out.


Generated by Claude Code

@os-justin
os-justin enabled auto-merge September 8, 2026 01:36
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
os-justin pushed a commit that referenced this pull request Sep 8, 2026
`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
Merged via the queue into main with commit 7cf6f38 Sep 8, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-8459-relatedlist-emptiness branch September 8, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant