Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .changeset/7064-empty-section-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
'@object-ui/plugin-detail': minor
---

⚠️ **Partly superseded inside this same release — read the `hideEmpty`
restoration entry for what ships.** What survives below: the direct-`fields`
fallback body and the `detail-view` node keep an all-empty section's skeleton
with zero app-side authoring, and the label-graveyard guard is untouched. What
does not: on an AUTHORED `record:details` section the empty-section default is
`hideEmpty` again, so an all-empty one hides unless the page writes
`hideEmpty: false`.

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
Expand All @@ -25,7 +33,10 @@ default.
What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
empty-value placeholder per field (it used to render nothing at all)
— ⚠️ re-reversed for AUTHORED `record:details` sections later in this same
release, where the restored `hideEmpty` owns this case and `false` is the
spelling that keeps the skeleton;
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
Expand Down
29 changes: 21 additions & 8 deletions .changeset/7129-retire-detailviewsection-hideempty.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,18 @@ than a patch; it retires no capability anyone could exercise.
One key had four contracts and three answers: `@object-ui/types` declared it,
`RecordDetailsRenderer` honoured it, the `DetailViewSectionSchema` zod mirror
omitted it, and the spec refused it. The maintainer converged the four on the
spec's answer (2026-09-01): the spec keeps refusing, the mirror stays absent,
and the declaration and the read are retired. All four are now pinned together
in `record-details.hideEmptyRetired-7129.test.tsx`.
spec's answer (2026-09-01) as it stood then. All four are pinned together in
`record-details.hideEmptyRetired-7129.test.tsx`.

⚠️ **What that convergence settled on has since moved, inside this same
release.** The premise was that the spec refuses the key — true of the 17.2.0
pin this repo held, and already false upstream. `@objectstack/spec` 17.3.0
declares the key with a description promising the behaviour this entry removed,
so the maintainer ruled the protocol correct and restored the read. Net for a
reader of THIS release: the key is declared upstream, honoured here, and
`hideEmpty: false` works. The paragraphs above describe a step this release
takes and then takes back; the restoration entry is the one that describes what
ships.

Going with it is the paradox the key carried: `DetailSection` tested
`!section.hideEmpty`, so an authored `hideEmpty: false` was indistinguishable
Expand All @@ -40,11 +49,15 @@ either polarity is now inert, and the release notes should read that way.
Everything else in it stands: the unauthored default is unchanged, and so is
the label-graveyard guard.

**Migration:** delete `hideEmpty` from any `record:details` section you author.
A section that used `hideEmpty: true` to hide an all-empty block will now show
that block's skeleton — headings, field labels and one empty-value placeholder
each. That is the platform's answer for a sparse record, and it is a UI
decision, not something metadata should have to make.
**⚠️ Migration: none — superseded inside this same release. ⛔ Do NOT delete
`hideEmpty` from your sections.** This entry originally told you to, because at
the time the key was retired here and refused by `@objectstack/spec`. Both
halves changed before this release shipped: the spec DECLARES
`RecordDetailsProps.sections[].hideEmpty` from 17.3.0, and the
`record:details` read is RESTORED later in this same release — see the
`hideEmpty` restoration entry, which states the behaviour that actually ships.
A section that authors `hideEmpty` keeps its meaning; `hideEmpty: false` is how
an all-empty section keeps its heading and label skeleton.

**Not affected**, despite the shared name: `record:reference_rail`'s own
`hideEmpty` prop, which is a different surface and still live; and the
Expand Down
18 changes: 18 additions & 0 deletions .changeset/8603-record-details-hide-empty-restored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/plugin-detail': minor
'@object-ui/types': minor
---

`record:details` honours `hideEmpty` on a section again — an all-empty section hides itself, `hideEmpty: false` keeps its heading and skeleton

**User-visible.** A `record:details` section whose fields are ALL empty now renders nothing at all — no heading, no skeleton — unless the page writes `hideEmpty: false` on it, which keeps the heading and the label skeleton a brand-new record needs. That is the behaviour `@objectstack/spec` declares on `RecordDetailsProps.sections[]` and describes in the key's own `describe()` text, and this renderer had stopped delivering it.

⚠️ **The unauthored default moved — and read that against the RELEASE, not against `main`.** An all-empty authored section rendered its skeleton on `main` from 2026-09-01, which is after the previous release, so the two entries that produced that state are still pending and ship alongside this one. Net for a consumer upgrading: the key is declared upstream and honoured here, `hideEmpty: false` works for the first time, and the empty-section default for an AUTHORED `record:details` section is unchanged from the last published release. Pages that want the skeleton on an all-empty section write `hideEmpty: false` — a spelling that parses green on the strict section object since spec 17.3.0, which is precisely what it could not do when the read was retired.

**Release-note reconciliation, done by correction rather than by rebuttal.** Two pending entries in this same release contradicted the above, and their prose has been corrected in place (frontmatter and declared packages untouched): `7129-retire-detailviewsection-hideempty.md`, whose migration step told authors to delete `hideEmpty` — an instruction a reader acts on, and one that would now delete the very spelling that keeps a skeleton — and `7064-empty-section-default.md`, whose "a sparse record keeps its section skeleton" holds for the fallback body and the `detail-view` node but no longer for an authored `record:details` section. Naming them from here instead would have left the migration step live in the same CHANGELOG.

**What did NOT change.** The empty ROWS of a section that still has a filled row stay with `DetailSection`'s auto-hide heuristic and the reader's "Show N empty fields" toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C, left standing by the ruling). Inline-edit mode renders an all-empty section either way, so its fields never become unreachable. `record:reference_rail`'s own component-level `hideEmpty` and the `detail.hideEmptyFields` toggle label are different keys and are untouched.

**Why it moved twice.** objectui#7129 (maintainer 2026-09-01) retired the declaration and the read because `@objectstack/spec` REFUSED the key — true at the 17.2.0 pin this repo held. Upstream had already declared it (17.3.0, upstream #11289, maintainer ruling 2026-08-23 direction 1, taken from a measured symptom), so once the pin moved the contract promised an author a behaviour the renderer no longer had, and it parsed green at publish. objectui#8603 (director seat batch #137 item 3, maintainer 2026-09-15) ruled the protocol correct and restored the read; #7129's Q1-A is superseded for this key only.

All four parties now agree — the spec declares it, `@object-ui/types` declares it, the `DetailViewSectionSchema` zod mirror carries it, and the renderer reads it — pinned together in `packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx`.
65 changes: 50 additions & 15 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,20 +230,18 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
[section.fields, isEmptyValue]
);

// Auto-hide-empty heuristic — the WHOLE contract for section emptiness
// (objectui#7129, maintainer 2026-09-01). When a section has empty rows AND
// Auto-hide-empty heuristic — the whole contract for the empty ROWS of a
// section that still has a filled one (objectui#7129 Q2-C, maintainer
// 2026-09-01, untouched by objectui#8603). When a section has empty rows AND
// at least one filled row, hide the empties so the page does not become a
// label-graveyard. The user can still reveal them with the toggle. 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.
// label-graveyard. The user can still reveal them with the toggle. An
// authored `hideEmpty` does NOT override this branch in either polarity —
// it never could: the read was `!section.hideEmpty`, so a written `false`
// was indistinguishable from unauthored, which is the paradox Q2-C settled.
//
// ⛔ There is deliberately no authored override. `DetailViewSection` used to
// declare `hideEmpty`, and this heuristic tested `!section.hideEmpty` — so
// an authored `false` was indistinguishable from unauthored and overrode
// nothing, while `@objectstack/spec` refused the key outright on a
// spec-validated page. The declaration is retired; do not reintroduce a read
// of it here (see `packages/types/src/views.ts` for the full four-party
// measurement).
// The ALL-EMPTY case is the other domain, and it is `hideEmpty`'s — see
// `allFieldsEmpty` below. The two are disjoint by construction: this
// heuristic requires `filledCount > 0`, that one requires `filledCount === 0`.
//
// Thresholds were tightened in Phase N (2026-05): smaller sections (≥4
// fields) and a lower empty ratio (≥25%) now trigger auto-hide so pages
Expand All @@ -258,9 +256,43 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
section.fields.length >= AUTO_HIDE_MIN_FIELDS &&
emptyCount / section.fields.length >= AUTO_HIDE_RATIO &&
filledCount > 0;
const hideEmptyEffective = !showEmptyOverride && shouldAutoHideEmpty;

// Filter out empty fields when the auto-hide heuristic kicked in.
// The authored `record:details` section key, restored under objectui#8603
// (director seat batch #137 item 3, maintainer 2026-09-15) after
// objectui#7129 retired it on the premise that `@objectstack/spec` refused
// the key — a premise the 17.3.0 pin move made false. The spec declares it
// on `RecordDetailsProps.sections[]` and its describe() promises exactly
// this: hiding is the renderer default, an all-empty section then renders
// nothing at all (no heading, no skeleton), and `false` keeps the heading
// and the label skeleton on an all-empty record.
//
// ⚠️ `=== true`, and the DEFAULT IS NOT HERE. `RecordDetailsRenderer`
// resolves it (`hideEmpty: s.hideEmpty ?? true` on the authored section),
// because "the renderer default" in that describe() is the default of the
// renderer the key is DECLARED on. This component also receives sections
// nobody could write the key on — the `record:details` direct-`fields`
// fallback body and the `detail-section` node both synthesize one, and
// neither surface declares `hideEmpty` — and hiding those would be a hide
// with no declarable spelling to ask the skeleton back, which is the exact
// defect upstream declared the key to fix. A truthiness test
// (`!section.hideEmpty`) is banned for the mirror-image reason: it is what
// made an authored `false` indistinguishable from unauthored before #7129.
//
// ⚠️ `!isEditing` is this renderer's boundary on the contract, and it is
// deliberate: inline-edit mode is the surface where those empty rows are the
// INPUTS. Hiding an all-empty section there would put its fields out of the
// author's and the user's reach entirely, which no describe() asks for and
// which would be a new defect rather than a restored behaviour. What the
// contract governs is what a READER sees, and that is what this leaves
// unchanged.
const allFieldsEmpty = section.fields.length > 0 && filledCount === 0;
const hideAllEmptySection = section.hideEmpty === true && allFieldsEmpty && !isEditing;

const hideEmptyEffective =
!showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection);

// Filter out empty fields when the auto-hide heuristic kicked in, or when an
// all-empty section is hiding itself (its early return is below every hook).
const visibleFields = hideEmptyEffective
? section.fields.filter((field) => !isEmptyValue(field))
: section.fields;
Expand Down Expand Up @@ -578,7 +610,10 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
}, [vsEnabled, layoutFields.length, vsBatchSize]);

// Hide entire section when all fields are empty AND the user has not asked to
// reveal them. This early return MUST come AFTER every hook above (including
// reveal them. Who decides this is the authored `hideEmpty` (`hideEmptyEffective`
// above, default on, `false` to keep the skeleton); this line is the mechanism
// it reaches, which is why restoring the read there needed no new exit here.
// This early return MUST come AFTER every hook above (including
// the virtual-scroll useEffect) — never before. When a section is all-empty
// on one render (early return, N hooks) but has data on the next render (the
// useEffect runs, N+1 hooks) of the SAME reconciled fiber, the hook count
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,34 @@ const specSectionKeys = (): string[] =>
* `renderers/record-details.tsx`: `s.showBorder` is honoured there beyond the
* spec's four; `title` was honoured as a strict-priority ALIAS of the heading
* slot (`s.title ?? s.label`) until objectui#6190 converged on the declared
* `label` and dropped the limb; `hideEmpty` was honoured until objectui#7129
* RETIRED the key (maintainer 2026-09-01) and left `DetailSection`'s auto-hide
* heuristic as the whole contract.
* `label` and dropped the limb.
*
* `title` and `hideEmpty` stay in this list on purpose, and dropping either
* would weaken the file. Membership is not "keys the renderer reads today" —
* it is "keys the spec refuses that the description must not advertise", and
* the spec refuses both whether or not anything reads them. A hand-kept list,
* but the ASSERTION
* filters it through the spec at runtime, so the day upstream declares one of
* these it drops out of the forbidden set on its own instead of pinning a stale
* prohibition.
* A hand-kept CANDIDATE set — keys this renderer honours on a section beyond
* the spec's original four — and ⛔ not a statement about which of them the
* spec refuses. The ASSERTION derives that per run by filtering this list
* through the installed schema, so a candidate upstream declares drops out of
* the forbidden set on its own instead of pinning a stale prohibition, and
* one upstream retires re-arms without an edit here. ⛔ Do not read the
* membership as a claim; read `stripped` in the assertion below.
*
* ⚠️ Which is why both names stay although they answer differently today:
* `title` is refused by the installed spec, `showBorder` is declared by it.
* Neither fact is written down as a verdict anywhere in this file.
*
* ⚠️ `hideEmpty` was a member and is GONE from the list under objectui#8603
* (director seat batch #137 item 3, maintainer 2026-09-15), which restored the
* renderer read after objectui#7129 retired it on the premise — falsified
* upstream by the 17.3.0 pin move — that the spec refused the key. The spec
* DECLARES it on the section entry, so it fails the membership criterion in
* its own words: it is not a key the spec refuses. The runtime filter above
* already dropped it from `stripped` before this edit, which is why removing
* it changes no verdict here — the list is what states the criterion, and
* leaving a declared key in it would state a prohibition the contract
* contradicts. The `sections` description now teaches `hideEmpty`, and the
* "every spec section member key is discoverable" case above is what requires
* that.
*/
const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder', 'hideEmpty'];
const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder'];

/**
* Does the installed spec REFUSE an undeclared key inside a `sections[]` entry,
Expand Down Expand Up @@ -239,11 +253,28 @@ describe('record:details — registry inputs vs @objectstack/spec', () => {
});

it('publishes no section member key the spec refuses to carry', () => {
// The renderer honours `showBorder` per section (and once honoured `title`
// and `hideEmpty`), but the spec's section object does not declare any of
// the three, so an author who writes them gets nothing back from the
// contract — a refusal, in fact. Documenting them here
// would teach keys the contract does not carry — the member-level twin of
// ⛔ Which of the candidates above the spec REFUSES is not stated here, and
// that is the point: it is derived below, per run, from the installed
// schema. `stripped` is that derivation, and only its members are the
// subject of the assertions that follow.
//
// ⚠️ Read the derivation, ⛔ not a remembered list. Of the two candidates,
// only `title` is refused by the installed spec today; `showBorder` IS
// declared on the section entry and therefore leaves `stripped` on its
// own. An earlier revision of this comment said the spec "does not declare
// it" of `showBorder` — false since the entry grew to its current member
// set, and false in a sentence no gate reads, which is commandment #9's
// own failure mode. The list stays a hand-kept CANDIDATE set (keys this
// renderer honours on a section beyond the spec's original four); the
// filter is what decides membership of the forbidden set, so a candidate
// the spec has since declared costs nothing and re-arms by itself if
// upstream ever retires it.
//
// The fixture below carries `hideEmpty`, which the spec DOES declare since
// 17.3.0 (the read restored here by objectui#8603): it is the live control
// on that filter — a key that leaves `stripped` and must therefore NOT
// appear among the refused names. Documenting a refused key here would
// teach one the contract does not carry — the member-level twin of
// publishing a top-level input the props schema rejects.
const stripped = RENDERER_ONLY_SECTION_KEYS.filter(
(key) => !specSectionKeys().includes(key),
Expand All @@ -267,6 +298,9 @@ describe('record:details — registry inputs vs @objectstack/spec', () => {
expect(refused).toEqual(expect.arrayContaining(stripped));
expect(refused).not.toContain('label');
expect(refused).not.toContain('fields');
// …and the key objectui#8603 restored is on the DECLARED side of that
// line: the same strict object that names `title` must not name it.
expect(refused).not.toContain('hideEmpty');
} else {
// The pinned rc.6: dropped in silence, which is the harm this file was
// filed over — success receipt, section renders without them.
Expand Down
Loading
Loading