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
9 changes: 7 additions & 2 deletions .changeset/6771-retire-body-child-list-dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ Ruled 2026-09-01: one concept, one spelling, and the spelling is `children`.
reads named below** — `div`, `card`, `button`, `aspect-ratio`, the sectioning tags,
the safe-HTML tag factory behind ~36 tags, `page`'s flat content list, and
`@object-ui/core`'s recursive `validateSchema`.
- **Item-level `body` is a DIFFERENT key and is untouched.** `list` draws each entry
as `item.content || renderChildren(item.body)` and `tabs` as
- **Item-level `body` is a DIFFERENT key and is untouched.** At this change, `list`
draws each entry as `item.content || renderChildren(item.body)` and `tabs` as
`item.content || item.body`, both filed under the ITEM type rather than the node,
and `tabs` still ships `body` inside its own `defaultProps`. ⛔ Neither is this
spelling and neither is refused here: the node-level retirement does not reach a
member of a declared `items` array. Retiring the item-level dialect is
objectui#9590's card, and the two named above are recorded on it.
⚠️ **Dated note, 2026-09-25 — neither half of this bullet holds any longer — objectui#9590.**
objectui#9941 respelled the `tabs` `defaultProps` items to `content`, and
objectui#9590 retired both item-level reads: a `list` item and a `tabs` item draw
`content` and nothing else, and both item faces now refuse `body` by name,
pointing at `content`. The rest of this bullet is kept as the reading of this change.
- **What still reads `body` at NODE level, and why.** Four renderer reads, all `page:*`: `page:card`
(renderer and Studio canvas) and the three thin `page:section` / `page:footer` /
`page:sidebar` containers. ⛔ Authoring the key is refused on them as it is
Expand Down
9 changes: 7 additions & 2 deletions .changeset/9256-list-timeline-content-channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ is now refused at its own path, at authoring time and at `safeParse` time. No
render behaviour changes: nothing read these keys, which is the whole reason
they could be refused.

⭐ The ITEM channel of `list` is a DIFFERENT key and is untouched. Its renderer
draws each entry as `item.content || renderChildren(item.body)` — a read filed
⭐ The ITEM channel of `list` is a DIFFERENT key and is untouched. At this change, its
renderer draws each entry as `item.content || renderChildren(item.body)` — a read filed
under `ListItem`, not under `ListSchema`. `items[].content` stays authorable and
is pinned as a live control; only the node's own two keys move.

⚠️ **Dated note, 2026-09-25 — the item read no longer falls back to `body` — objectui#9590.**
Later in this same release, `list` draws each entry from `content` alone (a string as-is,
a node or node array through `SchemaRenderer`), and `ListItem` refuses `body` by name,
pointing at `content`. The paragraph above is kept as the reading of this change.

These two were held out of the previous family-D slice for a SERIAL constraint
on `packages/types/src/data-display.ts` and never for a verdict. Readership was
re-derived for both rather than inherited: a TypeScript compiler-API sweep files
Expand Down
39 changes: 39 additions & 0 deletions .changeset/9590-item-body-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/components': minor
'@object-ui/types': minor
---

**BREAKING (authoring surface and render behaviour, shipped as `minor` per this repo's
version policy): an item-level `body` on a `list` item or a `tabs` item is refused at the
door. Author `content`.** A stored `list` or `tabs` item carrying `body` now fails
`ListItemSchema` / `TabItemSchema` with a refusal that names `content` (objectui#9590).

Neither item face ever declared `body`, yet both renderers read it as a fallback: `tabs`
drew `body` through an `any` cast when `content` was missing, and `list` drew
`item.content || renderChildren(item.body)`. The zod faces STRIPPED the undeclared key, so
the one spelling that rendered was the one the validator dropped in silence. That is the
lenient-fallback shape AGENTS.md #0.1 bans, and objectui#6771 retired the same spelling at
node level.

## What changed

- **`list` renders `content`.** A string `content` is placed as-is, as before. A node or an
array of nodes, the type `ListItem.content` declares, now renders through
`SchemaRenderer`. Before this change it made the whole list fail to render ("Objects are
not valid as a React child"), and an item-level `body` was the only way to put a node in
a list item. The `body` read is gone, and the registration's `items` input names
`content` only.
- **`tabs` renders `content`.** The `body` fallback is gone.
- **Both item faces refuse `body` by name.** `ListItem.body` and `TabItem.body` are `never`
on the TypeScript face. `ListItemSchema.body` and `TabItemSchema.body` are alias
refusals naming `content`, the same shape as `BaseSchema.body`, so `body` is refused at
`items.N.body` instead of being stripped. Every other accepted key is unchanged.

## Migrating

Rename the key on the item. `{ "body": node }` on a `list` item becomes
`{ "content": node }`, and `{ "value": "a", "label": "A", "body": [...] }` on a `tabs`
item becomes `{ "value": "a", "label": "A", "content": [...] }`. Nothing in this
repository authors the old spelling any more: the `tabs` registration's own `defaultProps`
were respelled by objectui#9941, and the one teaching example, in the published
`objectui` skill's expressions guide, now uses `content`.
9 changes: 7 additions & 2 deletions .changeset/9941-tabs-default-items-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ for it — `invalid_type ["content"] expected nonoptional, received undefined`,
measured against the built schema, with the lit control that the same item spelled
`content` parses green with keys `value,label,content`.

⚠️ No rendered output moves. `tabs.tsx` reads `item.content` first and falls back
to `body` through an `any` cast, so the same nodes move from the fallback arm onto
⚠️ No rendered output moves. At this change, `tabs.tsx` reads `item.content` first and
falls back to `body` through an `any` cast, so the same nodes move from the fallback arm onto
the primary one; the repair is at the parse and the rendered markup is unchanged
(asserted, not assumed). What changes for an author is that copying the shipped
default into authored metadata now validates.

The fallback arm itself is untouched and `TabItemSchema` is untouched — widening
the published accept set to admit `body` would pre-empt the `body`-dialect
question open on objectui#9871.

⚠️ **Dated note, 2026-09-25 — the fallback arm is retired and `TabItemSchema` refuses `body` — objectui#9590.**
Later in this same release, `tabs.tsx` draws `item.content` and nothing else, and
`TabItemSchema` refuses an item-level `body` by name, pointing at `content`. The
paragraphs above are kept as the reading of this change.
4 changes: 2 additions & 2 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,8 @@ For reference, a `tabs` node written with the keys `TabsSchema` actually declare
`value`, `label` and `content`: the identifier, the visible title, and the panel.
All three are required, and the two ways of getting an item wrong fail
differently. An item missing one of them is **refused**. An item that also
carries the older `title` / `body` spelling is **accepted with those two keys
dropped**, so the tab renders an empty panel with nothing naming the cause. On
carries the older `title` spelling is **accepted with that key dropped**, while
an item-level `body` is **refused by name**, pointed at `content`. On
the node itself, `body` and `children` are refused by name: `tabs` reads neither
content channel. The four keys it does render are `defaultValue`, `items`,
`orientation` and `value`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* A `list` item draws from `content`, and `content` renders the node(s) it
* declares (objectui#9590).
*
* ## What was wrong, measured before the change
*
* `ListItem.content` is declared `SchemaNode | SchemaNode[]` on both published
* faces (`ListItemSchema` cited by SYMBOL, objectui#8875). The renderer placed
* it into the `<li>` as a raw React child, so a string rendered but a node, or
* an array of nodes, made the whole `list` fail to render ("Objects are not
* valid as a React child"). The only channel that DID render a node was an
* item-level `body`, a key neither face declares, which the renderer read as a
* fallback. ⇒ the declared channel failed for nodes and the undeclared one
* worked, and the registration's own `items` input recommended `content/body`.
*
* Now `content` goes through `renderChildren`: a string is placed as-is,
* exactly as before, and a node or node array renders through
* `SchemaRenderer`. The `body` read is retired, and both published faces refuse
* the key by name (`list-tabs-item-body-refusal-9590.test.ts` in
* `@object-ui/types`).
*
* ## The rows
*
* - `nodeContent` / `nodeArrayContent` — red before the change (the list
* failed to render), green after.
* - `stringContent` — the CONTROL: green in both worlds. It keeps the
* subject rows from passing on a list that paints nothing at all.
* - `bodyOnly` — red before the change (the `body` node rendered), green
* after.
* - `registrationInput` — the `items` input no longer recommends `body`.
*
* ⛔ No row pins wording: the description row asserts which KEY it names.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Registered at module scope, NOT in a hook: a cold transform billed to
// `hookTimeout` is narrower than the timeout it replaces (objectui#3010).
import '../index';

afterEach(() => cleanup());

/** Render one `list` node through the real renderer and return what it paints. */
function paint(items: unknown[]): { text: string; li: number } {
render(
<SchemaRendererProvider dataSource={{} as never}>
<SchemaRenderer schema={{ type: 'list', items } as never} />
</SchemaRendererProvider>,
);
return {
text: document.body.textContent ?? '',
li: document.body.querySelectorAll('li').length,
};
}

const FAILED = 'failed to render';

describe('`list` items draw from `content`, and only `content` (objectui#9590)', () => {
it('nodeContent: a node-valued `content` renders the node', () => {
const { text, li } = paint([{ content: { type: 'text', content: 'NODE-CONTENT' } }]);
expect(text, 'the declared `content` type is a node; the list must render it').not.toContain(FAILED);
expect(li).toBe(1);
expect(text).toContain('NODE-CONTENT');
});

it('nodeArrayContent: an array of nodes in `content` renders every node', () => {
const { text, li } = paint([
{ content: [{ type: 'text', content: 'FIRST-NODE' }, { type: 'text', content: 'SECOND-NODE' }] },
]);
expect(text).not.toContain(FAILED);
expect(li).toBe(1);
expect(text).toContain('FIRST-NODE');
expect(text).toContain('SECOND-NODE');
});

it('stringContent (control): a string `content` is placed as-is', () => {
const { text, li } = paint([{ content: 'STRING-CONTENT' }, 'BARE-STRING']);
expect(li).toBe(2);
expect(text).toContain('STRING-CONTENT');
expect(text).toContain('BARE-STRING');
});

it('bodyOnly: an item authored with `body` alone paints nothing from it', () => {
const { text, li } = paint([{ body: { type: 'text', content: 'BODY-ONLY' } }]);
// Lit half of the same render: the row itself is there, so an empty `li`
// is a reading and not a list that never rendered.
expect(li).toBe(1);
expect(
text,
'the item-level `body` fallback is back: `ListItem` declares `content` and refuses `body`',
).not.toContain('BODY-ONLY');
});

it('registrationInput: the `items` input names `content` and not `body`', () => {
const items = ComponentRegistry.getMeta('list', 'ui')?.inputs?.find((input) => input.name === 'items');
expect(items, 'ui:list is not registered with an `items` input — this row is vacuous').toBeDefined();
const description = String(items?.description ?? '');
expect(description).toMatch(/\bcontent\b/);
expect(description).not.toMatch(/\bbody\b/);
});
});
4 changes: 2 additions & 2 deletions packages/components/src/renderers/data-display/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ ComponentRegistry.register('list',
>
{items.map((item: any, index: number) => (
<li key={index} className={cn(typeof item === 'object' && item.className)}>
{typeof item === 'string' ? item : item.content || renderChildren(item.body)}
{typeof item === 'string' ? item : renderChildren(item.content)}
</li>
))}
</ListTag>
Expand All @@ -57,7 +57,7 @@ ComponentRegistry.register('list',
{
name: 'items',
type: 'array',
description: 'Array of strings or objects with content/body'
description: 'Array of strings or objects with content'
},
{ name: 'className', type: 'string' }
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
*
* invalid_type ["content"] expected nonoptional, received undefined
*
* ⚠️ The defect was never visible in the rendered UI. `tabs.tsx` reads
* `renderChildren(item.content || (item as any).body)` — `content` first, `body`
* through an `any` cast — so the seeded nodes drew from the fallback arm. It was
* the DECLARED contract that refused them, and only a parse can see that.
* ⚠️ The defect was never visible in the rendered UI. At the time, `tabs.tsx`
* read `content` first and fell back to `body` through an `any` cast, so the
* seeded nodes drew from the fallback arm. It was the DECLARED contract that
* refused them, and only a parse can see that. objectui#9590 has since retired
* that fallback: `tabs.tsx` draws `item.content` and nothing else, pinned by
* `tabs-item-body-retired-9590.test.tsx`.
*
* ## Why this pin parses instead of reading the spelling
*
Expand Down Expand Up @@ -58,18 +60,18 @@
*
* ## ⛔ What this pin deliberately does NOT claim
*
* - ⛔ It does not assert that `body` is refused. `TabItemSchema` is a stripping
* `z.object`, so an undeclared `body` is silently DROPPED, not rejected;
* `undeclaredBodyIsDropped` records that as the measured behaviour rather
* than letting a later reader assume a strict face.
* - ⛔ It does not touch the `|| (item as any).body` fallback in `tabs.tsx`.
* Retiring that tolerance is the `body`-dialect family's question
* (objectui#9871, objectui#9910 — both open, both `needs-user-decision`), and
* a pin here must not answer it. ⚠️ `controlB.equality` below is the one row
* that READS that arm: it exists to prove this repair was parse-level and not
* visual. When the fallback is retired, that row retires WITH it — ⛔ it is
* not a reason to weaken the subject, and the durable half of control B
* (`controlB.stillRenders`) does not depend on the arm at all.
* - ⛔ Its refusal rows are about the ABSENT `content`, never about `body`.
* When this pin was written `TabItemSchema` STRIPPED an undeclared `body`;
* objectui#9590 has since made it a named refusal pointing at `content`
* (pinned in `@object-ui/types`, `list-tabs-item-body-refusal-9590.test.ts`),
* and `bodyIsRefusedByName` below records that as the measured behaviour.
* - ⛔ It does not decide the `body` fallback in `tabs.tsx`; objectui#9590
* retired it, and `tabs-item-body-retired-9590.test.tsx` is that change's pin.
* A `controlB.equality` row used to READ the fallback arm, to prove this
* repair was parse-level and not visual: the pre-fix spelling painted the
* same thing as the fixed one. It retired WITH the arm, as it said it would;
* the durable half of control B (`controlB.stillRenders`) never depended on
* the arm, and ⛔ no subject row moved.
* - ⛔ It is not widened past `ui:tabs`. Whether a registration's `defaultProps`
* may diverge from its published face across the repository is the open
* question on objectui#4631 (`pm:on-hold`), and deciding it by gate rather
Expand Down Expand Up @@ -149,18 +151,21 @@ describe('`ui:tabs` defaultProps.items satisfy the published TabItemSchema (obje
}
});

it('undeclaredBodyIsDropped — `body` is stripped by the parse, ⛔ not rejected', () => {
// Why the refusal above is about the ABSENT key and never about the
// present one: the face is a stripping `z.object`, so the item's own
// child list did not survive the parse even before the missing-key error.
const parsed = TabItemSchema.parse({
it('bodyIsRefusedByName — `body` beside `content` is refused at its own path', () => {
// Until objectui#9590 the face STRIPPED this key in silence; it is now a
// named refusal. The refusal row above still reads the ABSENT `content`,
// so it measures the seed's spelling and not this refusal.
const r = TabItemSchema.safeParse({
value: 'probe',
label: 'probe',
content: [{ type: 'text', content: 'kept' }],
body: [{ type: 'text', content: 'dropped' }],
body: [{ type: 'text', content: 'refused' }],
});
expect(Object.keys(parsed).sort()).toEqual(['content', 'label', 'value']);
expect(parsed).not.toHaveProperty('body');
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }))).toEqual([
{ code: 'invalid_type', path: 'body' },
]);
});
});

Expand Down Expand Up @@ -204,27 +209,11 @@ describe('`ui:tabs` defaultProps.items satisfy the published TabItemSchema (obje

describe('controlB — the UI did not move', () => {
it('stillRenders — the seed paints every label and the first tab body', () => {
// Durable half: reads only the `content` arm, so it survives whatever
// objectui#9871 rules about the fallback.
// Durable half: reads only the `content` arm, so it never depended on the
// `body` fallback objectui#9590 retired.
const { text } = renderSeed(seededItems());
for (const item of seededItems()) expect(text).toContain(String(item.label));
expect(text).toContain('Content for Tab 1');
});

it('equality — the pre-fix spelling paints exactly the same thing today', () => {
// ⚠️ THIS ROW READS THE FALLBACK ARM (`item.content || (item as any).body`).
// It is the proof that objectui#9941 was a parse-level repair and not a
// visual one: the same nodes moved from the fallback arm onto the primary
// one. It is ⛔ NOT a claim that the fallback must stay — when
// objectui#9871 retires it this row goes with it, and `stillRenders`
// above, plus every subject row, are untouched by that.
const after = renderSeed(seededItems());
cleanup();
const before = renderSeed(preFix(seededItems()));
expect(before.text).toBe(after.text);
expect(before.html).toBe(after.html);
// Lit control: ⛔ two empty renders are also equal.
expect(after.text).toContain('Content for Tab 1');
});
});
});
Loading
Loading