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
51 changes: 51 additions & 0 deletions .changeset/8284-content-channel-per-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@object-ui/types': minor
---

Resolve the `body` / `children` duality PER COMPONENT: twelve component schemas now
narrow to the content channel their renderer actually reads and tombstone the other on
both published faces (objectui#8284, maintainer ruling summon #17 decision batch #2,
2026-09-07).

**BREAKING for authored metadata, deliberately** — and `minor` because this repo's
fixed version group never ships `major` (see AGENTS.md 版本号策略).

`BaseSchema` declares two optional content channels 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 error at authoring time, none at validation time, none at render time. Seven
earlier cards repaired one page of that each (objectui#5027, #3900, #6773, #6806, #8197,
#8234, #6939) before the declaration itself was named.

**What changes.** For each component below, the channel its renderer does not read is now
`?: never` on the TypeScript face and refused BY NAME on the zod mirror, with a message
that names the channel to write instead:

| the renderer reads | components | now refused |
|---|---|---|
| `children` | `box`, `span`, `container`, `flex`, `stack`, `grid`, `scroll-area`, `form`, `toggle` | `body` |
| `body` | `alert`, `badge`, `tooltip` (which reads `content` first, `body` as its fallback) | `children` |

Which channel each renderer reads was measured with the TypeScript type checker over
every `ComponentRegistry.register(...)` call in `packages/components` — a read site is a
property access filed under the type of the object it is read from, so a docblock mention
cannot score. The full 114-row table, including the components deliberately NOT narrowed
here, is on objectui#8284.

**Migration.** Nothing that renders today stops rendering: a document authoring the
channel its renderer reads is unchanged, and a document authoring the other one rendered
an empty element before and is now refused instead. The repo-wide census found five
documents in this state — `packages/react/README.md`, `content/docs/guide/expressions.md`,
two blocks in `content/docs/guide/schema-rendering.md` and `packages/components/TESTING.md`
— every one of them a `form` or `container` authoring `body`; all five are corrected in
this change. If your own metadata authors the refused channel on one of these twelve node
types, the component was already drawing nothing there; rename the key to the one in the
table.

**Not narrowed here, and why.** Components whose renderer reads BOTH channels through a
live `children || body` fallback (`div`, `card`, `button`, `aspect-ratio`, the `page`
family), components that read neither, and components with no dedicated declaration
(`sidebar-*`, the `any`-typed registrations) keep both channels. Each is named on
objectui#8284 with the specific measurement it still needs; acting on any of them from the
`packages/components`-only sweep would have been a guess.
2 changes: 1 addition & 1 deletion content/docs/guide/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ or author each variant and gate it with a condition key:
```json
{
"type": "form",
"body": [
"children": [
{
"type": "select",
"name": "country",
Expand Down
4 changes: 2 additions & 2 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Use arrays for multiple items:
```json
{
"type": "container",
"body": [
"children": [
{ "type": "text", "content": "First item" },
{ "type": "text", "content": "Second item" },
{ "type": "text", "content": "Third item" }
Expand Down Expand Up @@ -429,7 +429,7 @@ Always type your schemas for better IDE support and fewer runtime errors.
```json
{
"type": "container",
"body": {
"children": {
"type": "spinner",
"visibleOn": "${loading}"
}
Expand Down
27 changes: 22 additions & 5 deletions examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,31 @@ describe('objectui#6939 — `children` is no longer required on either member',
expect(ContextMenuSchema.safeParse({ type: 'context-menu', items: [] }).success).toBe(true);
});

it('the accept set only WIDENED — the `children` spelling still parses', () => {
// The ruling's patch reasoning. Nothing that validated before this change
// may stop validating; `children` survives as `BaseSchema`'s optional key.
expect(TooltipSchema.safeParse({
it('objectui#8284 SUPERSEDES the widen-only half for `tooltip`: `children` is now REFUSED by name', () => {
// This case used to assert "the accept set only WIDENED — the `children`
// spelling still parses", on objectui#6939's reasoning that `children`
// survives as `BaseSchema`'s optional key. That half is now overruled for
// `tooltip` by the objectui#8284 ruling (summon #17, decision batch #2,
// 2026-09-07): the channel a renderer does not read is tombstoned per
// component, so authoring it is refused at validation instead of drawing
// an empty tooltip. #6939's own declaration already said "nothing reads
// `children` here" — this is that sentence made enforceable.
const refused = TooltipSchema.safeParse({
type: 'tooltip',
content: 'Helpful information',
children: [{ type: 'button', label: 'Hover me' }],
}).success).toBe(true);
});
expect(refused.success).toBe(false);
if (!refused.success) {
expect(refused.error.issues.map((i) => i.path.join('.'))).toEqual(['children']);
expect(refused.error.issues[0]!.message).toContain('objectui#8284');
}

// CONTROL, unchanged and deliberately so: `context-menu` is NOT in the
// family objectui#8284 narrowed (its renderer reads neither `body` nor
// `children`, and that verdict needs a cross-package sweep before it can
// be acted on). Its `children` still parses, which is what keeps the line
// above a reading about `tooltip` rather than about the whole mirror.
expect(ContextMenuSchema.safeParse({
type: 'context-menu',
items: [{ label: 'Copy' }],
Expand Down
2 changes: 1 addition & 1 deletion packages/components/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Components should have valid DOM structure:
it('should have valid structure', () => {
const { container } = renderComponent({
type: 'container',
body: [{ type: 'text', content: 'Content' }],
children: [{ type: 'text', content: 'Content' }],
});

const domCheck = checkDOMStructure(container);
Expand Down
2 changes: 1 addition & 1 deletion packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { SchemaRenderer } from '@object-ui/react'

const schema = {
type: 'form',
body: [
children: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
Expand Down
Loading
Loading