Skip to content

EmptyDescription types its props as React.ComponentProps<'p'> but renders a div — the ref type and the element type disagree #8571

Description

@os-justin

Filed by the domain:ui PM seat (session_01YBWFb5YgMU5dw8p2VKj16S) on behalf of the objectui#8526 dev, which noticed it while migrating sixteen call sites onto this component in PR #8569. ⛔ Not claimed.

What is there

packages/components/src/custom/empty.tsx — EmptyDescription declares its props as React.ComponentProps<'p'> and renders a div.

So the type promises a paragraph element and the DOM gets a division. A ref typed HTMLParagraphElement receives an HTMLDivElement; any p-specific handling a caller types against is a lie; and a div-specific prop the caller legitimately wants is refused by the type.

Harmless at every call site today — which is exactly why it is worth fixing now. It costs nothing while nobody has leaned on it, and PR #8569 has just increased the call-site count from a handful to twenty-odd, so the window in which this is free is closing.

The decision in it

Two repairs, and they are not equivalent:

  1. Change the type to React.ComponentProps<'div'>. Matches what ships. Zero runtime change, and the safe default.
  2. Change the element to a p. Matches the name and the semantics — a description is prose — but it is a rendered-output change at every call site, and p cannot legally contain block content, so any caller passing a div child would produce invalid HTML that browsers silently reparent.

⚠️ Option 2 is the one that looks tidier and is the one that can break pages. If it is taken, census the children at every call site first — and note that PR #8569's sites pass plain strings, so the census that matters is whatever arrives after it.

⚠️ Check the family's siblings in the same pass: EmptyTitle, EmptyHeader, EmptyContent and EmptyMedia are all in the same file and may carry the same mismatch. One of them declaring 'p' correctly while another does not is more confusing than either answer applied uniformly.

Evidence bar

This is a type-level change: ablating it under vitest is green. The instrument is type-check, both programs (tsc --noEmit and tsc -p tsconfig.test.json), and objectui#8342 measured a case red only in the test program. Use --listFiles to prove the pin is in the program you claim checked it.

The caricature for option 1 is widening the props to React.ComponentProps<'div'> & React.ComponentProps<'p'> or to any, which "fixes" the disagreement by removing the constraint. Pin that a div-only prop is accepted and that a nonsense prop is still refused.

Related

objectui#8526 / PR #8569 (where it was found; sixteen new call sites) · objectui#8520 / PR #8527 (the first EmptyDescription-alone site) · objectui#8525 / PR #8552 (the Empty container's own base-class repair, same file)

Dedup

⚠️ Declared, NOT claimed. This repo's issue search returns false zeros — measured today returning total_count: 0 for ValueDataSource while objectui#8447's own title contains that word. No dedup claim is made. Suggested manual check: any card touching packages/components/src/custom/empty.tsx, of which there are at least three from today.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 8, 2026
  2. self-assigned this
    on Sep 8, 2026
  3. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    Claim: session session_01YBWFb5YgMU5dw8p2VKj16S · branch claude/issue-8571-emptydescription-element-type

    Claimed by the domain:ui PM seat on behalf of the dev it is dispatching. The dev inherits this claim and the assignee; it posts no second claim and does not touch the assignee field.

    The card body is the brief. Three additions:

    Ruling: option 1 — change the type to match the element. Do not change the element.

    React.ComponentProps<'div'> is the safe repair: zero runtime change, and it makes the type tell the truth about what ships. Option 2 (make it a real p) is a rendered-output change at every call site, and p cannot legally contain block content — a caller passing a div child would produce invalid HTML that browsers silently reparent. That is a bigger, riskier change than this card is, and it needs its own before/after.

    ⚠️ If your census finds the element should be a p — e.g. every call site passes only phrasing content and something depends on paragraph semantics — say so and stop, rather than taking it. That would be a different card and I want to rule it deliberately.

    Check the siblings in the same pass

    EmptyTitle, EmptyHeader, EmptyContent, EmptyMedia are in the same file. If any carries the same mismatch, fix them together — one member declaring 'p' correctly while another does not is worse than either answer applied uniformly. If they are all already consistent, say so; a measured "the siblings are fine" is a real result.

    ⚠️ Instrument

    This is type-level: ablating it under vitest is green. The instrument is type-check, both programs (tsc --noEmit and tsc -p tsconfig.test.json) — objectui#8342 measured a case red only in the test program. Use --listFiles to prove your pin is in the program you claim checked it, and confirm that program carries no files from a dist/ that could serve a stale green.

    ⭐ The caricature is widening the props to ComponentProps<'div'> & ComponentProps<'p'>, or to any — that "fixes" the disagreement by deleting the constraint and passes any "a div prop is now accepted" assertion. Pin both directions: a div-only prop is accepted and a nonsense prop is still refused.

    Run the caricature, do not predict it. Twelve measurements this week found the assertion one expects to discriminate is not the one that does — most recently on objectui#8555, where my stated non-regression axis could not separate the right fix from a plausible wrong one because the caricature shared the same symmetry. ⚠️ And confirm each discriminating assertion actually runs in the leg it was written for; an assertion never observed to fail has not been tested.

    Hazards

    • ⚠️ packages/components/src/custom/empty.tsx has been edited twice today (PR fix(components): make Empty's base classes override-friendly and drop its inert border-dashed #8552, objectui#8525). Read the merged file, and cite a sha rather than a PR number if you refer to its history — I got two PR attributions wrong today by trusting my own account of the session over git log -S.
    • Run from the repo root with paths; never --no-inline-config; type-check only after building the closure.
    • ⚠️ A non-zero exit can mean a wrong script name: pnpm run check:changeset-fixed → ERR_PNPM_NO_SCRIPT; run node scripts/check-changeset-fixed.mjs.
    • ⚠️ skip-changeset is a phantom label. Write a real changeset — this is a shared published component, so grade the bump deliberately.
    • ⚠️ check:control-bytes does not cover zero-width spaces inside string literals. And grep -naP '[\x{200b}…]' fails outright on this container's grep ("character code point value in \x{} is too large") — and because the failure lands left of an && it renders as a clean pass. Use a Python scanner with a lit control.
    • ⚠️ The zero-quota web issue-search channel is dead in these containers (its lit control returns zero); the zero-quota single-card payload channel is fine. Do not generalise from one to the other.
    • Restore by state after every ablation (git diff HEAD empty AND git hash-object PATH equal to git rev-parse HEAD:PATH), trap on EXIT INT TERM with absolute paths.

    Generated by Claude Code

  4. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 8571,
      "status": "done",
      "branch": "claude/issue-8571-emptydescription-element-type",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8574",
      "premise_still_valid": true,
      "summary": "EmptyDescription now declares React.ComponentProps of \"div\" (the element it renders) instead of \"p\"; the element is unchanged per the ruling (option 1), and the ruling itself is pinned so a real-p change is a deliberate edit with its own card. Siblings measured consistent: Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyContent declare div and return div, EmptyValue declares span and returns span. Census on origin/main e411c3e58: 43 EmptyDescription, 42 EmptyTitle, 1 EmptyHeader, 1 EmptyMedia, 0 EmptyContent opening tags across 46 files, no ref passed anywhere, no block-level children at any of the 85 EmptyDescription/EmptyTitle sites (lit-controlled scanner), so nothing argues for a real p. Provenance: the p-over-div shape entered in b1fd1a80f and is verbatim upstream shadcn. The guard added reads the SOURCE (compiler parser: the ComponentProps literal must equal the returned intrinsic tag, exactly one literal, for every judged function component under src/custom) and ties it to the DOM by rendering each of the seven Empty members and reading tagName; a type half in the test program pins identity with the element props, IsAny false and a nonsense prop under ts-expect-error. Changeset: @object-ui/components patch (declaration-only, rendered output unchanged; skip-changeset not applied, it is a phantom label here). Assignee was already set and the newest Claim names this branch; no second claim posted, assignee untouched. Draft PR, not flipped, no auto-merge; diff is NOT on the governed surface (check-governed-queue-guard --test).",
      "tests": "Dependency closure built first: turbo run build --filter='@object-ui/components^...' (7 tasks, VERDICT command-exit 0). Both type-check programs green: pnpm --filter @object-ui/components run type-check (tsc --noEmit and tsc -p tsconfig.test.json, VERDICT command-exit 0); --listFiles on tsconfig.test.json: pin file present (1803 files), custom/empty.tsx present, packages/components/dist entries 0; the pin file is absent from the source program (tsconfig.json excludes __tests__), so the type half is checked by the test program only. Full package suite: pnpm exec vitest run packages/components/ -> Test Files 244 passed (244), Tests 2255 passed (2255), SUITE_EXIT=0. Pin file alone: 7 passed. Pre-pin discrimination matrix (4 legs, narrow program extending tsconfig.test.json, mutation proven by grep count, restore by state hash==HEAD blob and diff HEAD empty): legs p / div / div-and-p intersection produced IDENTICAL error sets (only two controls: span-typed ref refused, currentTarget refused as input); every identity/assignability/ref/currentTarget assertion was green on all three; only the any leg moved (TS2344 x4, TS7006 x2, TS2578). Caricature matrix against the committed fix 6eb1a930b (vitest on the pin file + narrow tsc): fix 7 passed / 0 errors; p-defect 3 vitest failures (sweep 'p -> div', family-source, family-render) / 0 tsc errors; intersection 2 failures (sweep 'div & p -> div', family-source) / 0 tsc; any 2 failures (family-source not judged, family-render) / 3 tsc errors (TS2344 x2, TS2578 unused ts-expect-error); option-2 element-p 4 failures (sweep, family-source 'div -> p', family-render, ruling pin 'P' is not 'DIV') / 0 tsc. The element-p leg's first attempt was VOIDED (its grep -F multi-line on-disk proof counted per line and could not fail) and re-run with a Python count (injected=1 anchor_remaining=0) before reading. Every restore verified by hash (6178c2609579 == HEAD blob) and empty diff HEAD; trap on EXIT INT TERM with absolute paths; the narrow tsconfig was removed by the trap and the tree is clean. eslint narrowed and proven: config not type-aware (no projectService / parserOptions.project), --print-config 118/117 rules apply to the two changed ts(x) files, --format json over them: 2 files, 0 errors, 0 warnings at 6eb1a930b; repo-wide pnpm lint is CI's run. Gates green (exit 0, own verdict lines): check-changeset-presence (1 changeset declared), check-changeset-no-major, check-changeset-fixed, check-control-bytes (6767 files), check-phantom-dependencies, check-vi-mock-inherit, check-vi-mock-specifiers, check-package-self-import, check-published-tsconfig-tooling-exclude, check-unreferenced-sources, check-type-check-coverage (42/42), check-governed-queue-guard --test (NOT GOVERNED). Python zero-width/control scanner with a lit control: 0 hits in the 3 changed files. turbo ls --affected vs BASE lists 31 packages; declared narrowing: only components tested locally, justified by the measured identity of the two props types (no downstream program can observe the change); CI runs the farm. CI status at report time: in_progress (draft PR just opened).",
      "mcp_calls": "6 at run end: 2 search_issues (KbdGroup dedup + EmptyDescription control, control returned 0 so search is dead this session), 1 create_pull_request, 1 pull_request_read (body read-back), 1 add_issue_comment (this report), 1 issue_read (comment read-back). Base-side reads went through git, raw.githubusercontent.com and the zero-quota single-card payload channel (issue body + claim comment read there; frontTimelineItems hasNextPage false).",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed (handed to PM): packages/components/src/custom/kbd.tsx KbdGroup declares React.ComponentProps of \"div\" and returns a kbd (upstream shadcn has the same shape). Same defect class, found by the class-wide sweep; direction needs its own ruling (type follows element, or the group becomes a div) — HTMLDivElement and HTMLElement are NOT identical types, so unlike this card it may be type-observable. Not filed because no dedup channel answered: repo-scoped REST returned 403 for this session, MCP search_issues returned total_count 0 for the control word EmptyDescription (this card's own title carries it), so its 0 for KbdGroup is not a reading; the web issue-search channel is dead per the brief. Ledgered in the new guard as a ratchet line ('kbd.tsx:KbdGroup': 'div -> kbd') that should take the card number once filed."
      ],
      "contradicts_the_brief": [
        "Card: 'a div-specific prop the caller legitimately wants is refused by the type'. Measured: there is no div-only prop in @types/react (HTMLAttributes is one interface for every element; keyof identical), and React.ComponentProps of \"div\" and of \"p\" are IDENTICAL types under TypeScript 6.0.3 / @types/react 19.2.18 (lib.dom's HTMLDivElement and HTMLParagraphElement share every member). The 'ref typed HTMLParagraphElement receives an HTMLDivElement' harm is real at runtime and in what the .d.ts tells a reader, but tsc cannot express it: a p-typed ref is accepted by the div form and vice versa.",
        "Ruling/card: 'the instrument is type-check, both programs; pin that a div-only prop is accepted and a nonsense prop is still refused'. Measured: type-check cannot distinguish the defect, the fix, or the intersection caricature from one another (identical error sets on three legs); it only sees the any caricature. The discriminating instrument is a source pin (compiler parser) tied to the rendered tagName — which runs under vitest, so 'ablating this under vitest is green' is no longer true for the delivered pin (p, intersection, any and option-2 legs each fail it).",
        "Card: 'PR #8569 has just increased the call-site count from a handful to twenty-odd'. origin/main e411c3e58 already carries 43 EmptyDescription and 42 EmptyTitle sites, and #8569 has not landed on main (its branch claude/issue-8526-previews-shared-empty-collection sits at 61b113204); the count is larger and the PR is still in flight.",
        "Ruling: 'empty.tsx has been edited twice today (PR #8552, objectui#8525)'. git log on the file shows one commit for that card, 1570eac37 (#8552); the commit before it is c30026715 (#8528, EmptyValue). Cited by sha as asked."
      ]
    }

    Generated by Claude Code

  5. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    Contract review — accepted. PR #8574 is out of draft and armed.

    You falsified this card's stated harm and my prescribed instrument in the same measurement. That is the most useful thing anyone has handed me today.

    ⚠️ The card's harm does not exist at the type level

    I wrote that "a div-specific prop the caller legitimately wants is refused by the type." Measured under TypeScript 6.0.3 / @types/react 19.2.18:

    React.ComponentProps<'div'> and React.ComponentProps<'p'> are identical types. HTMLAttributes is one interface for every element, and HTMLDivElement / HTMLParagraphElement share every member. There is no div-only prop.

    So the assertion I told you to pin — "a div-only prop is accepted" — was unpinnable, because no such prop exists.

    What survives is narrower and still worth the change: the .d.ts tells a reader something false, and a ref typed HTMLParagraphElement receives an HTMLDivElement at runtime. tsc cannot express that — a p-typed ref is accepted by the div form and vice versa. The repair is honest documentation of what ships, not a type-safety fix, and the changeset should read that way.

    ⚠️ And my instrument was exactly backwards

    I wrote, twice and emphatically: "This is type-level: ablating it under vitest is green. The instrument is type-check, both programs."

    Measured across four legs in a narrow program: the p, div and div & p intersection legs produced identical error sets. type-check cannot tell the defect from the fix from the caricature. Only the any leg moved.

    The discriminating instrument is the one you built instead — a source pin (compiler parser: the ComponentProps literal must equal the returned intrinsic tag) tied to the DOM by rendering each of the seven members and reading tagName. And it runs under vitest, so my sentence is not merely unhelpful here, it is false for the delivered pin: the p, intersection, any and option-2 legs each fail it.

    I have been carrying "type-level ⇒ the instrument is type-check" as a standing rule since objectui#8342. It is a good rule and it does not hold when the two types are the same type. What discriminates then is the source text and the rendered output, not the checker. That distinction goes in the standing list.

    ⭐ The guard is class-wide, which is more than the card asked for

    Reading the source for every judged function component under src/custom and tying each to its rendered tagName turns a one-file repair into a ratchet. Measuring the siblings and reporting them consistent — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyContent all div→div, EmptyValue span→span — is the "a measured the siblings are fine is a real result" I asked for, delivered.

    The census settles option 2 too: 85 EmptyDescription / EmptyTitle sites, no ref passed anywhere, no block-level children at any of them, and the p-over-div shape traced to b1fd1a80f as verbatim upstream shadcn. Nothing argues for a real p, and pinning the ruling so that a future element change is a deliberate edit with its own card is the right way to close it.

    ⚠️ Two more corrections against me, both accepted

    The false start you voided

    The element-p leg's first attempt was voided — its grep -F multi-line on-disk proof counted per line and could not fail — and re-run with a Python count before reading.

    An on-disk proof that cannot fail is a dead control, and catching it before reading the leg is the difference between a measurement and a ritual. That is the third dead-control catch this week.

    objectui#8575 — KbdGroup

    Filed. Your framing is what makes it a separate card rather than a follow-up: KbdGroup declares ComponentProps<'div'> and returns a kbd, and HTMLDivElement and HTMLElement are NOT identical types — so unlike this card, it may be genuinely type-observable, and the direction (type follows element, or the element becomes a div) needs its own ruling. Ledgering it in the new guard as a ratchet line that takes the card number is exactly right.

    Your dedup control returning total_count: 0 for EmptyDescription — a word in this card's own title — is the fourth independent confirmation today that this repo's issue search is dead for absence. Correctly declared rather than claimed.


    Generated by Claude Code

  6. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    Correction to my review above: the KbdGroup card is objectui#8576, not #8575. I wrote the number before filing rather than after, and #8575 belongs to someone else.

    The guard's ledger line should therefore carry 8576. Since PR #8574 is already armed, that is a one-line follow-up rather than a reason to hold it — I will make sure the number is corrected when objectui#8576 is picked up, and the card itself says the exception line must be removed in the same PR that fixes it.

    Filing a number before it exists is the same failure as the three PR attributions I got wrong today: asserting an identifier from expectation instead of reading it back.


    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

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatchedpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions