Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123
Conversation
…on default `RecordDetailsRenderer` mapped every authored section with `hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct rule in its own heuristic -- "If a section is entirely empty (e.g., loading state, brand-new record), do NOT auto-hide -- the labels themselves are useful as a structural skeleton" -- and the forced default overrode exactly the case that sentence reserves. On a hand-created sparse record whole sections disappeared and the body collapsed to a couple of rows. The renderer now passes the authored value through untouched. An unauthored section reaches DetailSection as `undefined` and the heuristic decides; an authored `hideEmpty` keeps its exact former meaning. Also drops a non-English comment from the slot (AGENTS.md commandment #-1). Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton, the below-threshold empty row, the intact label-graveyard guard, and both authored directions. Maintainer ruling 2026-08-31; objectui#7064. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
✅ 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
|
os-warren
commented
Sep 1, 2026
✅ ACCEPT (on the substance) — PM seat ( |
| party | hideEmpty on a record:details section |
|---|---|
@objectstack/spec 17.2.0 | ⛔ refuses (unrecognized_keys) |
@object-ui/typesviews.ts:230 | ✅ declares — hideEmpty?: boolean |
packages/types/src/zod/views.zod.ts | ⛔ absent — 0 hits, control headerColor = 2 in the same file |
RecordDetailsRenderer | ✅ honours |
The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.
⭐ You falsified my assumption about the test suite, and proved it rather than asserting it
I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.
The heuristic was verified by rendering, which the card's own framing invited you to skip
The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.
Ablation: an exact, quantified, mixed prediction
Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.
The precedence surprise: measured, pinned, and correctly not fixed
hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).
⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.
⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine
You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.
I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:
| query | result |
|---|---|
| your query, verbatim | 0 |
gantt date field names fabricated | 11 hits, #7070 top |
empty section skeleton sparse record detail | 1 hit — #7064 itself |
⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).
⭐ The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.
I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.
Routing the two open questions — both are the maintainer's
- Should
hideEmpty: falsebecome a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling. - Should the spec declare
hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.
Both go to the decision box on #7064, which therefore stays open past this landing.
hotcrm
Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7064
Stops
RecordDetailsRendererforcinghideEmpty: s.hideEmpty ?? trueonto everysection it synthesizes, so
DetailSection's own stated heuristic owns theunauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).
The one-line diff is
hideEmpty: s.hideEmpty ?? true,becominghideEmpty: s.hideEmpty,— the authored value now passes through untouched.Force sites swept
hideEmptyoccurs in 5 source files across the repo. Exactly one sitedefaulted a
record:detailssection'shideEmpty, and it is the one this PRchanges:
packages/plugin-detail/src/renderers/record-details.tsx:210packages/plugin-detail/src/DetailSection.tsx(heuristic +hideEmptyEffective+ toggle)packages/plugin-detail/src/renderers/record-reference-rail.tsx:232(schema.hideEmpty !== false)record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.packages/plugin-detail/src/index.tsx:731defaultValue: true) — belongs to the row abovepackages/types/src/views.ts:230No other renderer or mapper independently drops empty sections.
DetailViewrenders
schema.sections.map(...)unconditionally — every section-hidingdecision is
DetailSection's single early return.Heuristic verified by rendering, not inferred from the comment
Rendered
RecordDetailsRendererover a sparse record inside a realRecordContextProviderand dumped the DOM.container.textContentfor anall-empty 4-field section:
The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(
aria-label="No value" title="No value"). The heuristic is fully implemented;the forced default was the only thing suppressing it.
Precedence chain
hideEmptyis per section only — there is no page-level or view-levelhideEmptywith competing precedence. The effective chain insideDetailSectionis:showEmptyOverride) wins over everything;section.hideEmptyis truthy orshouldAutoHideEmptyfires;shouldAutoHideEmptyrequires!section.hideEmpty, not editing, at least 4fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
filled row — that last clause is what reserves the all-empty case;
nullonly when every field is empty and all ofthem got filtered out.
hideEmpty: falseis not anoverride. Step 3 reads
!section.hideEmpty, so an authoredfalseisindistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR —
?? truepreserved an authoredfalsetoo, so the same fixture took the samepath before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.
Behaviour delta (named in the changeset)
render nothing;
shows its empty rows;
and all — the label-graveyard guard is intact;
filled in place (
shouldAutoHideEmptyalready excludedisEditing; theforced default overrode that too).
Reference-app hit inside this repo: the Studio metadata-admin page preview
(
packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds areal sample record, so a
record:detailsblock over a sparse sample nowpreviews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shellwas touched. No application metadata anywhere needsediting — that is the point of the ruling.
Tests
New pin file
packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle,
hideEmpty: truehiding an all-emptysection (with a sibling section as the render control),
hideEmpty: truehidingrows in a partly-filled section,
hideEmpty: falseshowing them, and themeasured
false-is-not-an-override case.No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline
packages/plugin-detail/was 119 files /1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned
?? true.All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.
pnpm exec vitest run packages/plugin-detail/at1895c0965Test Files 120 passed (120)·Tests 1107 passed (1107)· lockVERDICT command-exit 071d83a6b1Test Files 119 passed (119)·Tests 1100 passed (1100)record:detailsinapps/console,packages/app-shell,packages/core,packages/typesTest Files 15 passed (15)·Tests 329 passed (329)pnpm --filter "@object-ui/plugin-detail" type-check(tsc --noEmit && tsc -p tsconfig.test.json)pnpm --filter "@object-ui/plugin-detail" lint(eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existingnode scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OKpnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OKpnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.pnpm check:sdui-registration-pinsa run with nothing to read has measured nothing. Build the console first); after building the console closure and the console:✅ All 16 registration(s) a sideEffects array promises are present in the built consoleTypecheck coverage is proven rather than assumed:
tsc -p tsconfig.test.json --listFileslists the new pin file (1 hit) alongside the existingrenderers/__tests__/record-details.test.tsxcontrol (1 hit) out of 1712 files,so the "typecheck is clean" claim really covers the new tests.
Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement:
eslint.config.jsdeclares noproject/projectService/parserOptions, so type-aware linting is off and a change inthese two files cannot move any verdict on an untouched file;
--format jsonover the two changed files reports 2 files,
0 errors, and 32 warnings all ofthe pre-existing
no-explicit-any/react-refreshfamilies the packagealready carries 894 of. The repo-wide run stays CI's.
Ablation
Predicted before running: restoring
?? trueturns exactly 2 of the 7 pinsred — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because
?? trueis a no-op on an authored value and produces anidentical DOM on the auto-hide path.
Observed:
Tests 2 failed | 5 passed (7), and the two failures are exactlythe two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:
…and the body printed by that failure is a
div.space-y-6wrapping a singleself-closed
div.space-y-3 sm:space-y-4— the section list element with nosection inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)
No rebuild leg is owed:
vitest.config.mtsaliases every@object-ui/*specifier to that package's
src, and the pin file imports../record-detailsrelatively, so the mutation runs from source with no
distin the path.Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (
showBorder: s.showBorder) 1, blobbee9ec88115d566c98ba314ca665ddd0f426f5c1to4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exitcode:
git checkout HEAD --against an absolute path (with anEXIT INT TERMtrap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1andgit diff HEADempty. The finalsuite run above is on the restored tree.
Falsified assumptions, reported not fixed
hideEmptyis not authorable on arecord:detailssection at@objectstack/spec17.2.0 — it is refused. The issue and the dispatch bothdescribe authored
hideEmptyas a declared opt-out. Measured against theinstalled spec:
RecordDetailsProps.safeParseon a section carryinghideEmpty: truereturnssuccess: falsewithunrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on thisrecord:detailssection". Control in the same probe: a section carryingcolumns: 2parses and the value survives. So for any spec-validated page thekey never reaches this renderer at all, and the old
?? truewas anunconditional platform policy with no author escape hatch. This PR is
unaffected — the renderer still honours an authored value for the schemas that
carry one (
@object-ui/typesdeclaresDetailViewSection.hideEmpty), and theflip is what actually gives spec-validated pages the right behaviour with zero
authoring. Flagged because the ruling's second clause reads differently once
you know this.
packages/plugin-detail/src/index.tsx:426-431says the spec's section object "STRIPS" undeclared keys on parse. Since the
#4001batch A work it refuses them loudly instead, per the measurementabove. Not in this card's class and in a file this PR does not otherwise
touch, so it is reported rather than fixed.
hotcrm acceptance is unverified by me
The acceptance scenario names hotcrm
opportunity_detail_pageandcase_detail_pageon hand-created records. hotcrm is not in this session'srepo scope and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRendererthrough the realDetailViewandDetailSectionwithsparse fixtures. The hotcrm scenario needs a run by someone with that repo.
Open question for the maintainer
Should
hideEmpty: falsebecome a hard override of the auto-hide heuristic?Today it means "not
true" (see Precedence chain). Left exactly as found andpinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes
falseon a large sparse section today getsno change in behaviour, which is its own quiet surprise.
Generated by Claude Code