fix(plugin-detail): the summary chip beside the H1 draws an object value through its own cell renderer - #8732
Conversation
…lue through its own cell renderer `effectiveSummaryFields`' chip displayed `String(val)` with four families special-cased, so an object-valued summary field — an expanded lookup payload, a location, an address — printed the literal `[object Object]` next to the page title, and its accessible name (built from the same string) said it too. Reachable both ways: `schema.summaryFields` is author-declared and never filtered by type, and the auto-detection can hand the status slot to a field whose stored value is not a scalar. Which question this chip asks was measured, not argued: it already prints `$1,235` for a stored `1234.5`, `Mar 4, 2026` for `'2026-03-04'` and `Closed Won` for `'won'` — the seen face, never the stored one — so the display authority for a kind it does not format is that kind's own cell renderer, the way `HeaderHighlight` reads it one band below. That route is not free. A Badge is a much smaller surface than a cell: with all 53 registered types rendered through `getCellRenderer` inside the real chip Badge against an object value, 15 draw a nested pill, an avatar composite, a bare `<img>` with no text, or a "No value" face for a value `hasCellValue` had just called filled. Those kinds are named with the measurement in `summaryChipRenderers.ts` and take `coerceToSafeValue`, this repo's single answer to the same question and byte-equal to what seven of them print in their own cell (objectui#8596). Nothing here invents a chip-local stringifier. The switch fires on the defect's own signature — the string path having produced the placeholder — so every value that already rendered, scalars and scalar arrays included, is byte-for-byte untouched, and the emptiness classification objectui#8394 converged onto `hasCellValue` is re-pinned rather than moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…ure can break Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…lar value, and add the changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
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
Size Limits
|
|
Contract review — accepted, flipped out of draft, auto-merge armed. 33/33 green. The thing that makes this PR right is that arm A was not taken on the argument that it was the obvious one. Which question the chip asks was settled by re-deriving the chip's existing behaviour: it already prints Three things I re-measured rather than read. 1. The fallback is not the defect in another costume. 2. The census closes the trap it names. A matrix pin over an empty set passes, and this one guards against that and meta-tests the guard: plus no-duplicate-types, 3. The gate is the defect's own signature, and it sits in the right place. The ⭐ Reported honestly, and it is the part I trust most: leg 5 first left
On the open question raised in the report — the dispatch text does read as self-contradictory (no model identifier in a commit message, then a mandated The two by-products (#8728, the percent chip stating two different percentages; #8729, the chip naming its field by the raw column name) were correctly kept out: the second one moves an instrument objectui#8394 owns. Generated by Claude Code |
Fixes #8464
The defect, reproduced on this branch's base
⭐ Leg 1. The card was filed from another dev's measurement on a different branch, so it was re-measured here —
DetailViewrendered oned971e8fcwithsummaryFields: ['owner_ref','billing_address','office_location']and three object values:aria-labelowner_ref(expanded lookup{id,name})[object Object]owner_ref: [object Object]billing_address({street,city,…})[object Object]billing_address: [object Object]office_location({latitude,longitude})[object Object]office_location: [object Object]H1 read
Acme Corporation, so all three pills sat beside a correctly-rendered title. Both halves of the card's claim hold: the text and the accessible name carry the placeholder, because the chip builds one from the other.Which question the chip asks — measured, not argued
objectui#8395 established on this page that "render what the user sees" and "render the underlying value" give different answers per field type, and that the intuitive choice was wrong for 9 of 17. That table is about a clipboard payload. Re-derived against today's tree (
packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsxstill pins it):daterendersMar 4against a stored2026-03-04,percentrenders12%against0.123,imageandbooleanrender no text at all — which is why a payload must take the stored value.The chip is not a payload. Its own existing behaviour answers which side it is on, measured here on
ed971e8fc:currency1234.5$1,235date'2026-03-04'Mar 4, 2026datetime'2024-07-04T07:00:00.000Z'Jul 4, 2024, 7:00 AMselect'won'Closed WonEvery one is the seen face, never the stored one. So the display authority for a kind the chip does not itself format is that kind's own cell renderer — option A, the route
HeaderHighlightalready takes one band below. This is the chip's measured behaviour, not a preference about what a summary "ought" to show.⭐ The per-kind Badge measurement — A is not free
The card's warning was taken literally: every one of the 53 registered field types was rendered through
getCellRendererinside the real chip Badge — same element, samevariant, same class stringDetailViewuses — against the object value{ id: 'acct-1', name: 'Acme Corp' }, and the DOM the pill received was counted. The instrument is committed assummaryChip.badgeFitCensus-8464.test.tsx, so the table stays runnable.38 of 53 fit. They draw plain inline text:
Acme Corpfor the nameable families; the JSON literal forlocation/geolocation/address/json/object/composite/record, behind objectui#8481's declared json-literal fence; a value-independent face forpassword/secret/vector/grid.15 of 53 do not, in four measured classes:
selectstatusmultiselectradiocheckboxestagsSelectCellRenderer's ownBadge— onerounded-fullnode nested inside the chip's ownuserrounded-fullnodes, and the initials glued onto the name:ACAcme Corpimageavatarsignature<img>,textContent === ''— nothing for the chip to say and nothing for its accessible namebooleantoggledatetimerepeater(sharedEmptyValue),date(formatDate's own em-dash, objectui#8581)hasCellValuecalled the value FILLED; a renderer answering "empty" one band later re-opens exactly the cross-band contradiction objectui#8394 closed.rounded-fullmatches two nodes per avatar (RadixAvatar.RootandAvatarFallback). This census counts rather than navigates, so it saw both; the census asserts the count is exactly 2.The decision this table produces
A for the 38 kinds that fit, with a stated rule for the 15 that do not — the shape the card anticipated, not a hedge.
The stated rule is not a stringifier written for this chip (objectui#8395's option C, "answer-shopping"). The refused kinds fall to
coerceToSafeValue,@object-ui/fields' single documented answer to "what text does a cell draw for a value that is not a string". objectui#8596 ruled the option families anduseronto exactly that text for an object value, so for 7 of the 15 the chip is byte-equal to its own cell. Following objectui#8596's method: read the existing authority per family, never invent a renderer-side fallback (AGENTS.md #0.1).⛔ Option B was refused by the card and is not in this change: no object value is dropped. The
THE EMPTINESS CLASSIFICATIONcases are red for it.Why the switch fires where it does
On the defect's own signature —
display.includes('[object Object]')after the existing four families have had their turn — not on a type guess.Number({})isNaN,new Date({})is Invalid and the option lookup falls back toString(val), so all four formatted branches land on the same test rather than needing four of their own. A value the string path already rendered (['a','b']→a,b, every scalar) is byte-for-byte untouched, andSCALARS AND SCALAR ARRAYSpins that.The one cost, stated
A renderer-backed chip carries no
aria-label:aria-labeloverrides content, so it would hide the very value the branch exists to show. Its accessible name is composed from content instead — ansr-onlyfield-name prefix plus the renderer's text — giving the samefield: valueshape as before, now true. String-path chips keep theiraria-labelunchanged, so objectui#8394'schipFornavigation and its'stage: Won'assertion are untouched. Every chip additionally carriesdata-summary-chip="<field>"as one handle for both paths.Non-regression: the emptiness classification did not move
objectui#8394 / PR #8457 converged this chip's guard onto
hasCellValueas "the non-regressive convergence — it moves whitespace-only strings and nothing else." This change is a display decision and leaves it alone; all four of its answers plus the empty-object case are re-pinned through the chip (THE EMPTINESS CLASSIFICATION, 5 cases). The whole existingpackages/plugin-detail/suite — 1350 tests — passes unchanged.Caricatures — every pin observed red
trap … EXIT INT TERM, absolute paths, the mutation proven on disk in both directions (removed-anchor count0, injected-anchor count ≥ 1,git hash-object≠ the HEAD blob) plus a line-total gate, and restoration proven by state (git diff HEADempty) after every leg. Per-test classification comes from vitest's JSON reporter.Vitest aliases
@object-ui/fields→packages/fields/src(vitest.config.mts:507), so a source mutation is what the test executes — nodistis in the path for these*.test.tsxfiles.DetailView.tsx: returnnullfor any object valueDetailView.tsx:ChipCellRenderer = getCellRenderer(...)unconditionallysummaryChipRenderers.ts:chipTakesCellRenderer→falsetextanswersfields/src/index.tsx:getCellRenderer→TextCellRendererfields/src/index.tsx:getCellRenderer→EmptyValueBoth directions the card asked for redden loudly. Leg 2 is the one that refuses "just route everything through the cell renderer": it reds all four formatted families, the percent bar, and the scalars.
Leg 5 initially left
THE FIT SIDE IS LOAD-BEARINGgreen — it had no assertion that mutation could break. That is reported rather than papered over: the case was given the discriminating assertion (a fitting kind draws its value, never thedata-slot="empty-value"affordance) and re-run, and it is red under leg 5 now.Verification
pnpm exec vitest run …summaryChip.objectValue-8464.test.tsx …summaryChip.badgeFitCensus-8464.test.tsxTest Files 2 passed (2)·Tests 26 passed (26)pnpm exec vitest run packages/plugin-detail/Tests 1350 passed (1350)pnpm exec vitest run packages/plugin-detail/ packages/plugin-calendar/ packages/plugin-gantt/ packages/plugin-kanban/ packages/runner/ packages/app-shell/Test Files 945 passed (945)· `Tests 8699 passedpnpm --filter '@object-ui/plugin-detail^...' build && pnpm --filter @object-ui/plugin-detail type-checktype-checkistsc --noEmit && tsc -p tsconfig.test.json, so the new test files are in a checked program, not excludedcheck:control-bytescheck:doc-fencescheck:phantom-depscheck:unused-depscheck:self-importgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over all six changed filesnode scripts/check-changeset-presence.mjs✅ 4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)— the gate's own verdict line.skip-changesetis a phantom label here; this is a user-visible fix, so it declares a realpatch, not an empty frontmatternode scripts/check-governed-queue-guard.mjs --test <the 6 paths>✅ NOT GOVERNED — 6 path(s) checked against 5 governed surface(s); none matched.Lint, narrowed and the narrowing measured.
pnpm exec eslint <the 4 code files> --format json→ exit 0, 4 files (count read from the JSON output, not asserted), 0 errors, 82 warnings — all@typescript-eslint/no-explicit-anyand two pre-existingreact-hookswarnings thatDetailView.tsxalready carried. The narrowing is a measurement rather than a skipped run because type-aware linting is not enabled:eslint.config.jshas zeroproject/projectServiceentries, so nothing in this diff can move the verdict on a file it did not touch. CI's repo-wideeslint .is the run that covers the population, and.github/workflows/lint.ymldeliberately sets no--max-warnings, so warnings are not a failure there either. ⛔--no-inline-configwas not used — that is an objectstack convention and manufactures errors this repo's CI does not have.Scope
search_issues— repo-scoped REST/search/issuesis refused in this container ("sessions are bound to their configured repositories"), so the suggested query was run through MCP. Its control fired: a query for this defect returned #8464 itself as the top hit, so the zero is real and not one of this tool's false zeros. No duplicate. #4054 is adjacent, not a duplicate — see the limit below.Two defects found on these lines and filed rather than ridden along:
percentsummary chip states two different percentages: its text reads0.123%while its own bar fills to 12.3%, and the body grid draws12%for the same value.owner_ref: …), the one band beside the H1 that does not resolve a label. Not folded in here because moving that half means editing the instrument objectui#8394 owns.Pixels. happy-dom reports
clientWidth: 0and never fires container-size effects, so the census measures what DOM the pill hosts, not what it clips. That half is #4054 — "arecord:highlightschip clips multi-element cell renderers with NO ellipsis" — which says in its own words that "a DOM-only probe reports this surface healthy" and needsgetBoundingClientRect()plus a screenshot. Its analysis also records thatlookup/user/filecarry their owntruncateand are unaffected, and names theMapPin-plus-text row — this repo'slocation— as a candidate. This change makeslocationreachable in a summary chip, so it is now in #4054's population; the structural verdicts here stand either way.Collisions
git ls-remote --heads origin 'gh-readonly-queue/*'at branch time listed PRs #8646, #8668, #8706, #8707, #8711. None touchespackages/plugin-detail/**(file lists read per PR). PR #8685 (plugin-detail) had landed, so the package was free.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code