Skip to content

record:details sections cannot survive an empty record: hideEmpty / collapsible / showBorder are honoured by the renderer but undeclared by the strict section schema #11289

Description

@os-zhuang

RecordDetailsProps.sections is strict and declares name / label / columns / fields. objectui's DetailSection reads three more keys off the same object — hideEmpty, collapsible, showBorder — and RecordDetailsRenderer spreads the authored section through to it, so those keys are honoured at runtime while objectstack validate warns that they are not props the component declares.

One of them is not cosmetic. It decides whether a section exists.

What the renderer does

packages/plugin-detail/src/renderers/record-details.tsx maps every authored section with hideEmpty: s.hideEmpty ?? true, and DetailSection then returns null outright:

if (visibleFields.length === 0 && emptyCount === section.fields.length) return null;

(Both verified in the shipped bundle: @objectstack/console 17.1.0, dist/assets/plugins-views-BaBStVok.js — hideEmpty:e.hideEmpty??!0 and j.length===0&&T===e.fields.length)return null.)

So on a record where a section's fields all happen to be empty, the section renders nothing at all — no heading, no empty shell, no prompt. DetailSection's own auto-hide heuristic has the opposite intent one screen above, and says so:

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.

The renderer's forced hideEmpty overrides exactly that case, and the author has no way to ask for the skeleton back, because the key that would ask for it is not declarable.

Why it matters to an author

Found while fixing objectstack-ai/hotcrm#1211. A freshly created opportunity there renders a Details tab with two rows: the highlights de-dup removes the fields the strip carries, the remaining ones are empty on a new record, and two of the three authored sections vanish. A "Description" section that says this deal has no description and no next step yet is more useful to a seller than no section — but there is no authored key that produces it. An app author's only options today are to author an undeclared key (warned, and the warning text asserts the key "did nothing", which is not true for collapsible — it reaches DetailSection intact via the open properties bag) or to accept the disappearance.

What would resolve it

Either direction closes the gap; the first is smaller:

  1. Declare the keys on RecordDetailsProps.sections — hideEmpty (boolean), and collapsible / showBorder while the shape is open — so declared = enforced and the author can keep a section that an empty record would otherwise erase.
  2. Or stop forcing hideEmpty in RecordDetailsRenderer and let DetailSection's stated heuristic own the all-empty case, so an empty section keeps its labels.

Either way, the strict schema and the renderer should agree on the same key set — right now the three keys sit on opposite sides of the contract.

Back-link: objectstack-ai/hotcrm#1211 (and the hotcrm PR that lands the authoring half references this card).

Activity

  1. added theissue type on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: needs-user-decision + domain:spec, type Bug. Verified against origin/main: the strict schema is RecordDetailsProps in packages/spec/src/ui/component.zod.ts — so direction 1 (declare hideEmpty / collapsible / showBorder) is a packages/spec accept-set widening on a published contract face: manual floor, spec seat's sole territory, Clause-② yes on implementation. Direction 2 (stop forcing hideEmpty in the renderer) is an objectui behavior change that alters what authored pages render. Either way a public face moves, and they produce different author capabilities — that choice is the maintainer's.

    [facets-block]

    • 实际业务需求:实测(hotcrm#1211)——新建记录上整段 section 无痕消失,卖家看不到「还没填」的骨架;作者今天没有任何合法拼写能把骨架要回来。
    • 项目长远合理性:renderer 已经读这三个键、strict schema 却拒绝它们,且 validate 的警告文案断言「键无效」是假话——契约与实现各说各话是最贵的一种债。
    • 防 AI 写代码犯错:AI 作者照 renderer 行为写 hideEmpty: false 会被 validate 警告误导删掉;声明即强制(方向 1)让 schema 与运行时说同一句话,结构性止错。
    • 创业阶段不扩散需求:方向 1 零新运行时代码(键已实现,只补声明+测试);方向 2 改默认渲染行为,波及所有存量页面。
      推荐:方向 1(声明三键,hideEmpty 默认值与 renderer 现状一致),方向 2 不做。回「1」/「2」即可;裁后为 spec 席 Clause-②: yes 实施卡。

    Generated by Claude Code

  3. claude commented on Aug 23, 2026

    @claude
    Contributor

    Maintainer ruling recorded (atomic four-part write)

    Ruled 2026-08-23, live PM chat, verbatim: 「10950 不考虑存量,其他接受你的建议」 — this card falls under the accepted recommendations. Ruled: direction 1 — declare hideEmpty / collapsible / showBorder on RecordDetailsProps (defaults matching current renderer behavior); direction 2 (renderer behavior change) is NOT approved. needs-user-decision → pm:queue in the same stroke. Implementation is Clause-②: yes (accept-set widening on a published face) — spec seat, contract-review tier.


    Generated by Claude Code

  4. self-assigned this
    on Aug 24, 2026
  5. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Claim: PM loop round R3
    Session: session_01Rxnd8cyFnoU8V5y21PaTsy
    Branch: claude/issue-11289-record-details-section-keys
    Worktree: objectstack-issue-11289
    Domain: domain:spec
    File surface: packages/spec/src/ui/component.zod.ts (RecordDetailsProps.sections only) + its tests, regenerated content/docs/references/ui/** pages the describe() edits trigger, api-surface regeneration if the type face moves, changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5 — mandated by clause-② (accept-set widening on a published contract face; the on-card ruling itself names contract-review tier); dispatch-gates --tier: "no path-derived mandate", clause-② suspect hint on packages/spec/src/** as expected
    Clause-②: yes
    Serial constraints cleared: #11575 (queued, same file component.zod.ts) is SERIALIZED BEHIND this card — fold-or-serial answered serial (different defect class: undeclared-but-honoured keys vs missing props-map rows; family gate ① fails), note being left on #11575 this stroke; all 18 open PRs scanned — none touches component.zod.ts; same-day churn: PR #11574 (merged 08-24) pinned empty properties bags in cloud-connection's envelope gate — sync main first; no pin assertions on this card's face by in-flight siblings known


    Generated by Claude Code

  6. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 11289,
      "status": "done",
      "branch": "claude/issue-11289-record-details-section-keys",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11662",
      "premise_still_valid": true,
      "summary": "Ruled direction 1 implemented: hideEmpty / collapsible / showBorder declared on the record:details section shape in packages/spec/src/ui/component.zod.ts, each z.boolean().optional() with NO schema default (PM mechanism assumption 1 confirmed, no fork: showBorder's renderer default is DERIVED from title presence — s.showBorder ?? (translatedTitle ? true : false) — so a materialized .default() could not express it). Renderer defaults MEASURED at both the .objectui-sha pin 190fbd01 and the objectui checkout e52fac05 (identical logic): hideEmpty ?? true with all-empty sections returning null; collapsible read as bare truthiness (off, initially expanded when on); showBorder derived. PM assumption 3 confirmed: the ~:1288/:1314 hideEmpty occurrences are record:reference_rail's, untouched. No objectui/renderer changes. Docs references regenerated via check:generated --fix; changeset @objectstack/spec minor. Draft PR #11662 at head 9dd48377, stays draft per clause-2.",
      "tests": "All readings at head 9dd48377 (no commits after any cited run; every verdict read from the gate's own printed line via redirect-then-capture, never through a pipe). spec full suite under os-verify-lock: 'Test Files 420 passed (420) / Tests 11222 passed (11222)', VERDICT command-exit 0; targeted component.test.ts post-amend 203/203. spec typecheck (tsc + scripts + test-typecheck): VERDICT command-exit 0. Reverse verification FROM COMMITTED STATE (git restore --source=BASE of component.zod.ts only): predicted-red observed on 2 of 3 discriminating tests (preservation + invalid_type, unrecognized-keys-shaped) — and the third stayed GREEN on the base schema, exposing a vacuous substring assertion in my own new test (toContain('showBorder') satisfied by the echoed 'showBorders'); fixed to the arrow-form assertion and re-run green 203/203. Deviation, reported honestly: my first mutation-on-disk anchor was NON-DISCRIMINATING (the phrase existed only in the test file, 0 hits in the schema in BOTH states), so that observation was void; the mutation evidence that stands is git status 'M component.zod.ts' at restore time plus the two predicted reds, and the restore was then verified with a discriminating anchor (1/1/0 across worktree/HEAD/BASE) and empty git diff HEAD. Consumer sweep, DOWNSTREAM direction (spec-lane duty): @objectstack/lint 'Test Files 81 passed / Tests 2266 passed | 5 skipped', VERDICT command-exit 0; fixture radius swept — lint's only hideEmpty fixture is record:reference_rail's. check:generated: 'All 14 generated artifacts are up to date' after --fix regenerated the 2 proved stale (api-surface: no content change after rebuild; references/ui/component.mdx: the one-row sections diff). dispatch-gates.mjs run WITHOUT self-supplied paths, --repo objectstack-ai/objectstack asserted, derivation stderr read (repo + commit confirmed): 34 path-matched + test-file-kind convention gates. 38 ran: 36 green first pass; 2 reds (check:dev-prereqs, lint check:doc-security-posture) were the fresh worktree's unbuilt-packages precondition — after a full 70/70 turbo build under lock (VERDICT command-exit 0) both re-ran green ('67 package build artifacts present'; '26 ObjectSchema.create examples ... validate-clean security posture'). check:type-check-debt --re-measure (workspace closure built first, as its own text requires): 'OK — 32 ledger entr(ies) re-measured in 291.3s, 1897 raw tsc error(s) total, none above its recorded number', VERDICT command-exit 0. check:type-check-coverage: OK. check:nul-bytes: green. Remote CI on PR #11662: in_progress at report time (CI convergence is PM's per the standing ruling).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11661: five more renderer-honoured record:details section keys (title / defaultCollapsed / icon / description / headerColor) still undeclared by the strict section schema — same defect class, outside this card's ruled three-key scope; headerColor may be a renderer bug (template-literal Tailwind class) rather than a declarable key"
      ]
    }

    Generated by Claude Code

  7. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    ACCEPT — reviewer of record: spec seat, session_01Rxnd8cyFnoU8V5y21PaTsy (R3 W1). PR: #11662 (draft, head 9dd48377).

    Verified against GitHub, not the report's prose: the diff is exactly the ruled direction-1 scope — three keys on the record:details section shape, z.boolean().optional() with NO schema default (the file's own maxVisible principle; showBorder's measured renderer default is DERIVED from title presence, which a materialized .default() could not even express — the documented-default reading is confirmed, no fork), renderer untouched (zero objectui files in the changed set), describe() texts carry defaults measured at the .objectui-sha pin, record:reference_rail's own hideEmpty untouched, references page regenerated by tooling, changeset minor. Test quality above bar: reverse verification from committed state caught the dev's own vacuous substring assertion and sharpened it to the arrow form — that discipline is the reason the suggestion-map pin is real. Full spec suite 11222 green, lint consumer sweep green with fixture radius swept, 38 gate families green (2 environmental reds proven precondition-only via full 70/70 build then re-run), check:type-check-debt --re-measure OK. Out-of-scope finding #11661 filed and verified (five more undeclared section keys, correctly not absorbed).

    Clause-② determination (one line, per the enqueue gate): path limb FIRES — the diff touches packages/spec/src/ui/component.zod.ts (contract surface) — and the claim declared Clause-②: yes; dispatch tier was claude-fable-5 (meets CONTRACT_REVIEW_TIER), and needs:contract-review is hung on BOTH carriers (this card + PR #11662) in this same stroke. This seat stops here: ⛔ no self-clearing — the contract-review chain clears the gate; flip-ready/enqueue happen only after that PASS plus every check green on the PR head.


    Generated by Claude Code

  8. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Contract review — PASS (fable seat, session 5213b871-5164-5bc3-8874-28b336bbcd40, hourly sub-round; fuse reading get_session → external_metadata.last_served_model = claude-fable-5, matching CONTRACT_REVIEW_TIER read from origin/main at scripts/pm/dispatch-gates.mjs:3070; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」). Independence: dispatched by the spec seat — independent review. Ruling verified on-card: direction 1, 2026-08-23 「10950 不考虑存量,其他接受你的建议」 batch.

    Reviewed PR #11662 @ 9dd48377 against the actual diff.

    • The widening is exactly the ruled three keys and nothing else: hideEmpty / collapsible / showBorder as z.boolean().optional() on the record:details section shape; renderer untouched (zero objectui files in the diff); direction 2 correctly not smuggled in.
    • The no-schema-default reading is the faithful one, not a deviation: 「defaults matching current renderer behavior」 is honoured by documenting the renderer's fallbacks in describe() while materializing nothing — a .default() would rewrite "author said nothing" into "author asked for it", and showBorder's measured default (derived from title presence) cannot even be expressed as a static default. The clean-parse pin ('hideEmpty' in section === false) locks that distinction.
    • Strictness survives the widening, non-vacuously: unknown keys still refuse, and the suggestion-map pin demands the arrow form `showBorders` → `showBorder` — the dev's reverse verification caught their own vacuous substring assertion (satisfied by the echoed offending key on the PRE-declaration schema) and sharpened it, reported honestly rather than fitted. Per-key invalid_type with pinned path covers the type edge.
    • Docs/changeset halves clean: component.mdx row regenerated by tooling (check:generated --fix), changeset @objectstack/spec minor — the right grade for an accept-set widening; record:reference_rail's own hideEmpty untouched. Out-of-scope five-key sibling correctly filed as record:details sections: five more renderer-honoured keys remain undeclared by the strict section schema (title / defaultCollapsed / icon / description / headerColor) #11661, not absorbed.
    • Measurements at the reviewed head: spec suite 420/420 files · 11222 tests, gate families green (2 environmental reds proven precondition-only via full build then re-run), check:type-check-debt --re-measure OK, reverse verification from committed state with discriminating anchors and empty-diff restore proof. Clause-② discipline kept (draft, dual-carrier label, no flip).

    Verdict: PASS. Clearing needs:contract-review on both carriers (card + PR #11662). Enqueue/flip belongs to the dispatching seat's landing window (remote CI was in_progress at report time — the seat's own convergence duty).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions