feat(types)!: narrow twelve component schemas to the content channel their renderer reads (objectui#8284, family A+B) - #9254
Conversation
…their renderer reads `BaseSchema` declares two optional content channels, `body` and `children`, and its own docblock admits that "some components use `children` instead of `body`" without saying which. The zod base is `.passthrough()` and both keys are optional, so a node carrying the wrong channel type-checked, parsed green, was preserved by the parse, and then rendered an EMPTY element — no signal at authoring time, at validation time, or at render time. Resolve it per component, not with a second alias: each of the twelve schemas whose renderer was MEASURED to read exactly one channel now tombstones the other, `?: never` on the TypeScript face and a by-name refusal on the zod mirror whose message points at the channel to write instead. Reads `children`, refuses `body`: box, span, container, flex, stack, grid, scroll-area, form, toggle. Reads `body`, refuses `children`: alert, badge, tooltip. The read side is measured with the TypeScript type checker over every ComponentRegistry registration in packages/components, so a docblock mention cannot score as a read. Five documents were found authoring a channel their renderer never reads and are corrected here; they rendered nothing before. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ 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
|
…9 pin, and cite read sites by content Two follow-ups measured after the first commit. `overlay-trigger-mirror-6939.test.tsx` pinned "the accept set only WIDENED — the `children` spelling still parses" for `tooltip`, on the reasoning that `children` survives as BaseSchema's optional key. That half is exactly what the objectui#8284 ruling overrules for this component: the channel a renderer does not read is refused instead of drawing an empty tooltip. The case now asserts the refusal and keeps `context-menu` — deliberately not narrowed — as the live control beside it. The new docblocks and mirror messages cited read sites as file:line. `check-new-cross-file-line-citations.mjs` reports that as the pattern objectui#7853 / objectui#8875 asked authors to stop using, so each now names the read EXPRESSION and the file. Count went 47 new citations to 0; no pre-existing docblock is touched. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ 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
|
PM ruling — Q1 → D (severed, with a deviation I am carrying to the maintainer). Q2 → C.
⭐⭐ First: this delivery corrected the RULING's own figure, and it did it the right wayThe ruling names 34 components; the card's body says a 17/17 split. Measured with a TypeScript compiler-API read-site sweep: there are 114 registrations under ⇒ both numbers were grep artifacts — That is this lane's rule 4 — measure the CALL, not the NAME — applied to a director ruling's own cardinal, by the seat executing it. ⛔ And nothing about the ruling's substance changes: it says "every component schema narrows to the channel its renderer actually reads", and "actually reads" is precisely what was measured. The cardinal was decoration; the instruction was not. ⭐ And the order was right: the table was posted on the card (comment 5643712599) BEFORE any declaration moved. Ruling step (1) executed as step (1). Q1 — the dev-time warning slice → D: severedThe seat's ground is measured, not preference: today 102 of 114 registrations are still undeclared. A warning wired from family A+B alone would fire on 12 components and stay silent on the 60 that read NEITHER channel — ⭐ and that is exactly where objectui#8197 and objectui#6939 actually live. ⇒ shipping it now produces a warning that is quiet precisely where the three already-patched instances were. ⛔ Not B: a per-call-site helper is the hand list the dispatch forbids — it covers only what someone remembered to wire. ⛔ Not A now: pulling a 403 KB zod mirror into ⭐ C is the end state and I am recording that here so it is not rediscovered: registration metadata plus a gate that re-derives the read sites and fails when a registration's declaration disagrees is the only option that also answers "what catches a new member" — which is the standing requirement on every enumeration in this lane. It lands with or after the remaining families, ⛔ not before them. ⇒ severed, and it goes on the maintainer's list as a deviation from the ruling's sequencing, with this measurement attached. Q2 — families C / D / E → C: both, in that order, E last
⭐⭐ LEG 2 — the fourth independent confirmation tonight, and the sharpestDeleting
⇒ the same mutation is invisible to vitest and loud under tsc — "reporting only vitest would have recorded NOT MEASURED as green." LEG 1 (the zod face) reddens vitest 5/88, so the two faces are pinned by different readers and the seat ran both. ⭐ Four seats have now hit this independently tonight; it is no longer a lesson, it is the lane's standard. Two more things worth naming
Findings carried, not filed — and I agree with not filing themNine Contract review follows once CI settles. Generated by Claude Code |
Contract review — head
|
Part of #8284 — deliberately not a closing keyword: this lands families A and B of the measured table, and the card stays open for families C, D and E.
Executes the ruling recorded on objectui#8284 (director seat, summon #17, decision batch #2, 2026-09-07; maintainer verbatim 「同意」): the
body/childrenduality is resolved per component, not by a second alias — every component schema narrows to the channel its renderer actually reads and tombstones the other.Ruling execution step (1), the measured table of all components, is posted on the card: objectui#8284 comment 5643712599. It went up before any declaration moved, as the ruling requires.
Attribution for this work: session
session_01UzHd6hDYatoDn17BuwKxnZ(written into the prose because aPATCHdegrades the footer form).What this does
childrenbox,span,container,flex,stack,grid,scroll-area,form,togglebodybodyalert,badge,tooltip(readscontentfirst,bodyas its fallback)childrenBoth published faces move together, in the shape PR #7774 established:
?: neveron the TypeScript face (atscerror at the authoring site) and a declared, by-name refusal at the key's own path on the zod mirror. The mirror arm isaliasKeyRefusal()rather thanretirementTombstone()— same file, samez.neverprimitive, sameinvalid_typecode, same "one string feeds the parse message and the.describe()metadata" discipline — because the guidance here has to carry the CANONICAL SPELLING, which is exactly the message that helper composes and exactly whatretirementTombstone's own docblock says it is not for. Reviewer's call if that reads as a deviation from the ruling's "the shape PR #7774 already established"; the shape is unchanged, only the sentence differs.Tombstones stay MEMBERS of both shapes, so
zod-mirror-parity.test.ts's key sets stay equal and an authored wrong channel is refused by name at its own path — not stripped, not admitted.The measurement, and why grep could not do it
Which channel each renderer reads was measured with the TypeScript type checker: every
body/childrenproperty access, element access and binding element is filed under the TYPE of the object it is read from. A docblock is not an AST node, so it cannot score.Lit controls, both directions:
BoxSchemadocblock inrenderers/layout/box.tsxsaysschema.bodyin prose.grep -lcounts it; the checker does not. Scoring it would have madeboxa both-channel component and this PR would have narrowed nothing there.action-button.tsx/action-icon.tsxcarryschema.bodyExtraandschema.bodyShape. Agrepforschema.bodycounts both; the checker does not.17 / 17figure, and today'sgrep -lfigure of 14 / 15, are wrong in both directions.Population derived from every
ComponentRegistry.register(...)call underpackages/components/src/renderers/**: 114 registrations, not the ruling's inherited figure of 34. 60 read neither channel, 9 read both, 32 have no per-component declaration to narrow; each group is named on the card with the measurement it still needs. Narrowing any of them from apackages/components-only sweep would have been a guess, and a truncated sweep is not a sweep.A brace-matched object-literal census over 7,173 files (positive control: 128 documents authoring the channel each renderer DOES read, so the query is live) found 14 documents authoring a channel their renderer never reads.
Five of them are prose, four published, and every one renders an EMPTY element today:
packages/react/README.mdformbodychildrencontent/docs/guide/expressions.mdformbodychildrencontent/docs/guide/schema-rendering.md(two blocks)containerbodychildrenpackages/components/TESTING.mdcontainerbodychildrenReported rather than migrated in silence: this is the card's own defect, found a sixth and seventh time in published material, and it is the whole migration story. The other nine hits are deliberate counter-probes in
examples/schema-catalog/test/andpackages/components/src/__tests__/span-children-rendering.test.tsxthat author the dead channel and assert the empty render; they are untouched and keep passing, because the tombstone refuses attscand atsafeParse, never at render.One pin genuinely changes verdict, and it is the right one
examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsxasserted "the accept set only WIDENED — thechildrenspelling still parses" fortooltip. objectui#6939's own declaration already says "nothing readschildrenhere"; this ruling makes that sentence enforceable, so the case now asserts the refusal.context-menusits in the same case as a live control and is deliberately NOT narrowed — itschildrenstill parses, which is what keeps that line a reading abouttooltiprather than about the whole mirror.Version level
minorwith a BREAKING banner in the changeset. This narrows a published authoring type, but AGENTS.md 版本号策略 forbidsmajorin any changeset — the fixed group of 41 packages would leave@objectstack's major train — and recordsminorplus an explicit breaking note as the spelling for a breaking change in this repo. Split levels are impossible anyway: onefixedgroup.Ablation — both readers, because half the pin is compile-time
Run on
d3fe8e010, the branch head, from the committed tree. Each leg mutates, proves the mutation landed on disk, runs both readers, and restores withgit checkout HEAD -- FILEproved by an emptygit diff HEAD(atrap … EXIT INT TERMcarries the restore on any exit path).tsc -p packages/types/tsconfig.test.jsonScrollAreaSchema'sbodyrefusal arm1 → 0scroll-areacase, including the nested oneScrollAreaSchema'sbody?: neverbody?: neverin that interface1 → 0TS2578 Unused '@ts-expect-error' directiveplusTS2322inzod-mirror-parity.test.tsgit diff HEADempty⭐ Leg 2 is the whole reason both readers are reported. The identical mutation is invisible to vitest —
?: neveris erased before a test runs — and loud undertsc. Had this been read through vitest alone, "the parity test covers it" would have been recorded as measured when it was NOT MEASURED. Leg 2 also lit a second, independent reader:zod-mirror-parity.test.tsturns red because the mirror would then refuse a key the declaration admits.Verification
Run on
d3fe8e010.pnpm exec vitest run packages/types/ examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsxpnpm exec vitest run packages/components/pnpm exec vitest run examples/schema-catalog/ packages/core/ packages/react/pnpm --filter @object-ui/types type-check(--noEmit+tsconfig.examples.json+tsconfig.test.json)type-checkfor@object-ui/components,@object-ui/react,@object-ui/core,@object-ui/layout,@object-ui/example-schema-catalog(eachtsc --noEmit && tsc -p tsconfig.test.json)turbo run buildover the 34-package doc closurepnpm check:doc-snippetspnpm check:doc-examplescheck-doc-fence-languages.mjscheck-doc-component-types.mjscheck-changeset-presence.mjscheck-changeset-no-major.mjsmajorcheck-control-bytes.mjscheck-new-cross-file-line-citations.mjscheck-governed-queue-guard.mjs --testpnpm --filter @object-ui/types --filter @object-ui/example-schema-catalog linteslint . --no-inline-config --format jsonover the WHOLE populationno-explicit-anyon untouched lines. Repo total 95 errors / 13,008 warnings, all outside this diff. No narrowing argument needed: the full population was read.Declared to CI, deliberately not run here
turbo run type-checkacross all 41 packages, the fullpnpm test, and the rest of thelint.ymlgate farm. Every gate that fires on THIS diff's shape was run locally and is in the table.🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ