diff --git a/.changeset/6771-retire-body-child-list-dialect.md b/.changeset/6771-retire-body-child-list-dialect.md index f3b6aba15b..cd6f46c356 100644 --- a/.changeset/6771-retire-body-child-list-dialect.md +++ b/.changeset/6771-retire-body-child-list-dialect.md @@ -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 diff --git a/.changeset/9256-list-timeline-content-channels.md b/.changeset/9256-list-timeline-content-channels.md index 368412b6ab..d9ce162474 100644 --- a/.changeset/9256-list-timeline-content-channels.md +++ b/.changeset/9256-list-timeline-content-channels.md @@ -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 diff --git a/.changeset/9590-item-body-retired.md b/.changeset/9590-item-body-retired.md new file mode 100644 index 0000000000..b80d7d8f6f --- /dev/null +++ b/.changeset/9590-item-body-retired.md @@ -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`. diff --git a/.changeset/9941-tabs-default-items-content.md b/.changeset/9941-tabs-default-items-content.md index 83a8346a0b..1a848452ea 100644 --- a/.changeset/9941-tabs-default-items-content.md +++ b/.changeset/9941-tabs-default-items-content.md @@ -12,8 +12,8 @@ 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. @@ -21,3 +21,8 @@ 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. diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 372f403574..fd652b4a3f 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -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`. diff --git a/packages/components/src/renderers/__tests__/list-item-content-channel-9590.test.tsx b/packages/components/src/renderers/__tests__/list-item-content-channel-9590.test.tsx new file mode 100644 index 0000000000..9454b29708 --- /dev/null +++ b/packages/components/src/renderers/__tests__/list-item-content-channel-9590.test.tsx @@ -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 `
  • ` 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( + + + , + ); + 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/); + }); +}); diff --git a/packages/components/src/renderers/data-display/list.tsx b/packages/components/src/renderers/data-display/list.tsx index d159dab4db..21ff7dc82a 100644 --- a/packages/components/src/renderers/data-display/list.tsx +++ b/packages/components/src/renderers/data-display/list.tsx @@ -41,7 +41,7 @@ ComponentRegistry.register('list', > {items.map((item: any, index: number) => (
  • - {typeof item === 'string' ? item : item.content || renderChildren(item.body)} + {typeof item === 'string' ? item : renderChildren(item.content)}
  • ))} @@ -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' } ], diff --git a/packages/components/src/renderers/layout/__tests__/tabs-default-items-parse-9941.test.tsx b/packages/components/src/renderers/layout/__tests__/tabs-default-items-parse-9941.test.tsx index 36dbff3140..555feef5e0 100644 --- a/packages/components/src/renderers/layout/__tests__/tabs-default-items-parse-9941.test.tsx +++ b/packages/components/src/renderers/layout/__tests__/tabs-default-items-parse-9941.test.tsx @@ -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 * @@ -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 @@ -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' }, + ]); }); }); @@ -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'); - }); }); }); diff --git a/packages/components/src/renderers/layout/__tests__/tabs-item-body-retired-9590.test.tsx b/packages/components/src/renderers/layout/__tests__/tabs-item-body-retired-9590.test.tsx new file mode 100644 index 0000000000..11e3f725a5 --- /dev/null +++ b/packages/components/src/renderers/layout/__tests__/tabs-item-body-retired-9590.test.tsx @@ -0,0 +1,86 @@ +/** + * 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 `tabs` item draws its panel from `content`, and ONLY from `content` + * (objectui#9590). + * + * ## What was retired + * + * The `ui:tabs` renderer drew each panel as `item.content`, falling back to an + * item-level `body` reached through an `any` cast. `TabItem` never declared + * `body` on either published face: `TabItemSchema` (cited by SYMBOL, + * objectui#8875) declares `content` REQUIRED and used to strip an undeclared + * `body`. So the fallback honoured a key the contract did not have: the + * lenient-fallback shape AGENTS.md #0.1 bans by name. The same change made + * both item faces refuse `body` by name, naming `content`. Nothing in + * this tree authors it: objectui#9941 respelled the registration's own + * `defaultProps` items to `content`, and the producer scan + * (`pnpm census:body-dialect-producers`) is the instrument that re-derives that. + * + * ## The rows + * + * - `subject`: an item authored with `body` alone paints NOTHING from it. Red + * while the fallback exists (the panel shows the body text), green once it is + * gone. + * - `control`: an item authored with `content` still paints its panel. Green in + * both worlds; it is what keeps `subject` from passing on a renderer that + * paints no panel at all. + * - `precedence`: `content` beside `body` paints `content` and never `body`. + * Green in both worlds as well (the fallback only ran when `content` was + * falsy); it pins that the retirement did not move the primary arm. + * + * ⛔ No row pins message prose, and ⛔ none reads the parse: this file is the + * RENDER half. The door half (the named refusal) is pinned in + * `@object-ui/types`, `list-tabs-item-body-refusal-9590.test.ts`. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +// Registered at module scope, NOT in a hook: a cold transform billed to +// `hookTimeout` is narrower than the timeout it replaces (objectui#3010). +import '../../../renderers'; + +afterEach(() => cleanup()); + +type Item = Record; + +/** Render one `tabs` node whose only tab is active, and return its text. */ +function paint(item: Item): string { + const C = ComponentRegistry.get('tabs', 'ui'); + if (!C) throw new Error('no renderer registered for ui:tabs'); + render(); + return document.body.textContent ?? ''; +} + +const node = (text: string) => [{ type: 'text', content: text }]; + +describe('`ui:tabs` draws an item panel from `content` only (objectui#9590)', () => { + it('subject: an item authored with `body` alone paints nothing from it', () => { + const text = paint({ body: node('BODY-ONLY-PANEL') }); + // Lit half of the same render: the tab strip itself painted, so an empty + // panel below is a reading and not a render that never happened. + expect(text).toContain('Only tab'); + expect( + text, + 'the item-level `body` fallback is back: `TabItem` declares `content` and refuses `body`, ' + + 'so the renderer must not honour it (AGENTS.md #0.1)', + ).not.toContain('BODY-ONLY-PANEL'); + }); + + it('control: an item authored with `content` paints its panel', () => { + expect(paint({ content: node('CONTENT-PANEL') })).toContain('CONTENT-PANEL'); + }); + + it('precedence: `content` beside `body` paints `content`, never `body`', () => { + const text = paint({ content: node('CONTENT-WINS'), body: node('BODY-LOSES') }); + expect(text).toContain('CONTENT-WINS'); + expect(text).not.toContain('BODY-LOSES'); + }); +}); diff --git a/packages/components/src/renderers/layout/tabs.tsx b/packages/components/src/renderers/layout/tabs.tsx index cfa19819de..3808f959e8 100644 --- a/packages/components/src/renderers/layout/tabs.tsx +++ b/packages/components/src/renderers/layout/tabs.tsx @@ -64,7 +64,7 @@ const TabsRenderer = ({ schema, className, onChange, value, ...props }: { schema value={item.value} className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", isVertical && "mt-0 flex-1")} > - {renderChildren(item.content || (item as any).body)} + {renderChildren(item.content)} ))} diff --git a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts index 6e47db0299..c1d8692ff0 100644 --- a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts @@ -68,10 +68,12 @@ * neither" for the wrong reason. Attributed directly instead: the whole of * `packages/plugin-timeline` contains NO `body` / `children` read, on any * receiver. - * - ⚠️ `list`'s renderer DOES read a `body` — `item.content || - * renderChildren(item.body)` — but that is the ITEM channel, filed under - * `ListItem` and not under `ListSchema`, the same shape as `tabs`. A LIVE - * CONTROL below keeps the item channel parsing. + * - ⚠️ `list`'s renderer read an ITEM-level `body` when this was written + * (`item.content || renderChildren(item.body)`) — the ITEM channel, filed + * under `ListItem` and not under `ListSchema`, the same shape as `tabs`. + * objectui#9590 has since retired that item-level `body` (both renderers + * draw `content` only, and both item faces refuse `body` by name). A LIVE + * CONTROL below keeps the item channel, `content`, parsing. * - ⛔ `button` stays out, and the reason CHANGED under re-derivation. * `check:registry-bare-names` reports `ui:button` as the sole owner of the * bare key — but that owner's renderer reads @@ -430,10 +432,12 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, }); it('LIVE CONTROL — the ITEM-level channel is untouched: a `tabs` item still parses `content`', () => { - // `tabs.tsx` renders `item.content || (item as any).body` — an ITEM key, - // filed under `TabItem` and not under `TabsSchema`. An instrument that - // attributed that read to the NODE would have made `tabs` a body reader and - // this whole row a mistake, so the item channel is pinned as still live. + // `tabs.tsx` renders each item's `content` — an ITEM key, filed under + // `TabItem` and not under `TabsSchema`, and `content` is neither of the two + // node channels the row above refuses. Until objectui#9590 that read fell + // back to an item-level `body`; an instrument that attributed THAT read to + // the NODE would have made `tabs` a body reader and this whole row a + // mistake, so the item channel is pinned as still live. expect(issues(TabsMirror as unknown as Mirror, { type: 'tabs', items: [{ value: 't1', label: 'One', content: CONTENT }], @@ -441,12 +445,14 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, }); it('LIVE CONTROL — `list`\'s ITEM channel is untouched: an item still parses `content`', () => { - // `list.tsx` draws each entry as `item.content || renderChildren(item.body)` - // — an ITEM read, filed under `ListItem` and NOT under `ListSchema`. An - // instrument that attributed it to the NODE would have made `list` a `body` - // reader and the row above a mistake, so the item channel is pinned as still - // live. `content` is the key `ListItemSchema` declares; the row above is - // about the node's own two keys and touches neither. + // `list.tsx` draws each entry from its `content` (`renderChildren(item.content)`) + // — an ITEM read, filed under `ListItem` and NOT under `ListSchema`, and + // `content` is neither of the two node channels the row above refuses. + // Until objectui#9590 that read fell back to an item-level `body`; an + // instrument that attributed THAT read to the NODE would have made `list` a + // `body` reader and the row above a mistake, so the item channel is pinned + // as still live. `content` is the key `ListItemSchema` declares; the row + // above is about the node's own two keys and touches neither. expect(issues(ListMirror as unknown as Mirror, { type: 'list', items: [{ label: 'One', content: CONTENT }], diff --git a/packages/types/src/__tests__/list-tabs-item-body-refusal-9590.test.ts b/packages/types/src/__tests__/list-tabs-item-body-refusal-9590.test.ts new file mode 100644 index 0000000000..b7fc53adbf --- /dev/null +++ b/packages/types/src/__tests__/list-tabs-item-body-refusal-9590.test.ts @@ -0,0 +1,133 @@ +/** + * 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. + */ + +/** + * objectui#9590 — an item-level `body` on a `list` item or a `tabs` item is + * REFUSED BY NAME on both published faces, and the refusal names `content`. + * + * ## The defect, measured before this change + * + * `ListItemSchema` and `TabItemSchema` are STRIPPING `z.object`s that declared + * no `body`: a list item authored `{ "body": node }` parsed GREEN to `{}`, and + * `body` beside a tab item's `content` was dropped in silence. The renderers + * meanwhile READ that key as a fallback, so the one spelling the faces dropped + * was the one that rendered. objectui#9590 retired both reads (each item now + * draws `content` only), which left `body` a key that is stripped at the door + * AND rendered by nothing: the silently-inert shape ADR-0049 ends. So the key + * is refused by name, in the shape the node-level retirement took + * (`BaseSchema.body`, objectui#6771): `aliasKeyRefusal` on the zod face, a + * `?: never` twin on the TypeScript face. + * + * ## The rows + * + * - (a) the zod doors: each item schema refuses `body` at its own path, alone + * or beside `content`, and through the node schema at `items.N.body`. Red + * before this change (every one parsed green). + * - (b) the CONTROLS: `content` (string, node, node array) and every other + * accepted key still parse, keys unchanged. Green in both worlds. + * - (c) the TypeScript faces: a NON-FRESH object carrying `body` no longer + * assigns. A fresh literal was already an excess-property error before this + * change, so it could not discriminate; the non-fresh assignment compiled + * before and is a `tsc` error now. `tsc -p tsconfig.test.json` is the reader. + * + * ⛔ Message prose is not pinned: an issue is asserted by `code` and `path`, and + * by the one named subject the refusal exists to carry, the key `content`. + */ + +import { describe, it, expect } from 'vitest'; +import type { ListItem } from '../data-display'; +import type { TabItem } from '../layout'; +import { ListItemSchema, ListSchema } from '../zod/data-display.zod'; +import { TabItemSchema, TabsSchema } from '../zod/layout.zod'; + +const NODE = { type: 'text', content: 'x' }; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +function issuesOf(r: { success: boolean; error?: { issues: Issue[] } }): Issue[] { + return r.success ? [] : (r.error?.issues ?? []); +} +function bodyIssue(r: { success: boolean; error?: { issues: Issue[] } }, path: PropertyKey[]): Issue | undefined { + return issuesOf(r).find((i) => i.code === 'invalid_type' && i.path.join('.') === path.join('.')); +} + +describe('objectui#9590 (a) — the item doors refuse `body` by name', () => { + it('ListItemSchema refuses `body` alone, and names `content`', () => { + const r = ListItemSchema.safeParse({ body: NODE }); + expect(r.success).toBe(false); + const issue = bodyIssue(r, ['body']); + expect(issue, JSON.stringify(issuesOf(r))).toBeDefined(); + expect(issue?.message).toContain('`content`'); + }); + + it('ListItemSchema refuses `body` beside `content`', () => { + const r = ListItemSchema.safeParse({ content: 'a', body: NODE }); + expect(r.success).toBe(false); + expect(bodyIssue(r, ['body'])).toBeDefined(); + }); + + it('ListSchema refuses it at `items.0.body`', () => { + const r = ListSchema.safeParse({ type: 'list', items: [{ body: NODE }] }); + expect(r.success).toBe(false); + expect(bodyIssue(r, ['items', 0, 'body']), JSON.stringify(issuesOf(r))).toBeDefined(); + }); + + it('TabItemSchema refuses `body` beside `content`, and names `content`', () => { + const r = TabItemSchema.safeParse({ value: 'a', label: 'A', content: NODE, body: NODE }); + expect(r.success).toBe(false); + const issue = bodyIssue(r, ['body']); + expect(issue, JSON.stringify(issuesOf(r))).toBeDefined(); + expect(issue?.message).toContain('`content`'); + }); + + it('TabsSchema refuses it at `items.0.body`', () => { + const r = TabsSchema.safeParse({ type: 'tabs', items: [{ value: 'a', label: 'A', content: NODE, body: NODE }] }); + expect(r.success).toBe(false); + expect(bodyIssue(r, ['items', 0, 'body']), JSON.stringify(issuesOf(r))).toBeDefined(); + }); +}); + +describe('objectui#9590 (b) — CONTROLS: `content` and every other key still parse', () => { + it('a list item\'s `content` parses as a string, a node and a node array', () => { + for (const content of ['a', NODE, [NODE, NODE]]) { + const r = ListItemSchema.safeParse({ content }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + } + }); + + it('a list item carrying every other declared key parses with its keys unchanged', () => { + const item = { id: 'i', label: 'L', description: 'D', icon: 'check', avatar: '/a.png', disabled: false, content: NODE }; + const r = ListItemSchema.safeParse(item); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + if (r.success) expect(Object.keys(r.data).sort()).toEqual(Object.keys(item).sort()); + }); + + it('a tab item carrying every declared key parses with its keys unchanged', () => { + const item = { value: 'a', label: 'A', icon: 'check', disabled: true, content: [NODE] }; + const r = TabItemSchema.safeParse(item); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + if (r.success) expect(Object.keys(r.data).sort()).toEqual(Object.keys(item).sort()); + }); +}); + +describe('objectui#9590 (c) — the TypeScript faces refuse `body` too', () => { + it('a non-fresh object carrying `body` assigns to neither item type', () => { + const listItemWithBody = { content: 'a', body: [NODE] }; + // @ts-expect-error objectui#9590 — `ListItem.body` is `never`; author `content` + const listItem: ListItem = listItemWithBody; + + const tabItemWithBody = { value: 'a', label: 'A', content: [NODE], body: [NODE] }; + // @ts-expect-error objectui#9590 — `TabItem.body` is `never`; author `content` + const tabItem: TabItem = tabItemWithBody; + + // CONTROL (no `@ts-expect-error`): the same objects without `body` assign. + const listOk: ListItem = { content: 'a' }; + const tabOk: TabItem = { value: 'a', label: 'A', content: [NODE] }; + + expect([listItem, tabItem, listOk, tabOk]).toHaveLength(4); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index add5c58358..463adb3393 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -326,11 +326,12 @@ export interface ListSchema extends BaseSchema { * `wrapperClass` (in `packages/components/src/renderers/data-display/list.tsx`). * * ⭐ THE ITEM CHANNEL IS A DIFFERENT KEY AND STAYS LIVE. That renderer draws - * each entry as `item.content || renderChildren(item.body)` — a read filed - * under {@link ListItem}, NOT under this node. An instrument that attributed - * it here would have made `list` a `body` reader and this tombstone a - * mistake, so the item channel is pinned as still live in - * `__tests__/content-channel-family-d-9256.test.ts`. + * each entry from its `content` (`renderChildren(item.content)`) — a read filed + * under {@link ListItem}, NOT under this node, and `content` is neither of the + * two node channels refused here. Until objectui#9590 that read fell back to an + * item-level `body`; an instrument that attributed THAT read here would have + * made `list` a `body` reader and this tombstone a mistake, so the item channel + * is pinned as still live in `__tests__/content-channel-family-d-9256.test.ts`. * * ⭐ WHY THIS NAME WAS HELD OUT OF objectui#9544, AND WHAT CHANGED. It was * held for a SERIAL constraint, ⛔ never a verdict: this file was being @@ -371,11 +372,12 @@ export interface ListSchema extends BaseSchema { * `wrapperClass` (in `packages/components/src/renderers/data-display/list.tsx`). * * ⭐ THE ITEM CHANNEL IS A DIFFERENT KEY AND STAYS LIVE. That renderer draws - * each entry as `item.content || renderChildren(item.body)` — a read filed - * under {@link ListItem}, NOT under this node. An instrument that attributed - * it here would have made `list` a `body` reader and this tombstone a - * mistake, so the item channel is pinned as still live in - * `__tests__/content-channel-family-d-9256.test.ts`. + * each entry from its `content` (`renderChildren(item.content)`) — a read filed + * under {@link ListItem}, NOT under this node, and `content` is neither of the + * two node channels refused here. Until objectui#9590 that read fell back to an + * item-level `body`; an instrument that attributed THAT read here would have + * made `list` a `body` reader and this tombstone a mistake, so the item channel + * is pinned as still live in `__tests__/content-channel-family-d-9256.test.ts`. * * ⭐ WHY THIS NAME WAS HELD OUT OF objectui#9544, AND WHAT CHANGED. It was * held for a SERIAL constraint, ⛔ never a verdict: this file was being @@ -440,9 +442,23 @@ export interface ListItem { */ onClick?: never; /** - * Item content (schema nodes) + * Item content — THE item's content key. A string is placed as-is; a node or + * an array of nodes renders through `SchemaRenderer` (objectui#9590). */ content?: SchemaNode | SchemaNode[]; + /** + * RETIRED (objectui#9590) — author `content`. + * + * The `list` renderer used to fall back to an item-level `body` when + * `content` was missing, and that fallback was the only way a node rendered + * inside an item, because `content` was placed as a raw React child. This + * face never declared `body`. The renderer now draws `content` through + * `renderChildren`, the fallback is gone, and the key is refused by name: + * `tsc` here, and `ListItemSchema`'s alias refusal naming `content` at parse. + * + * @deprecated Retired item-level spelling of `content` — author `content`. + */ + body?: never; } /** diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 2d2b1dc05d..20ee70ed70 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -1016,9 +1016,20 @@ export interface TabItem { */ disabled?: boolean; /** - * Tab content + * Tab content — THE tab item's content key. */ content: SchemaNode | SchemaNode[]; + /** + * RETIRED (objectui#9590) — author `content`. + * + * The `tabs` renderer used to fall back to an item-level `body` through an + * `any` cast when `content` was missing. This face never declared `body`. + * The fallback is gone and the key is refused by name: `tsc` here, and + * `TabItemSchema`'s alias refusal naming `content` at parse. + * + * @deprecated Retired item-level spelling of `content` — author `content`. + */ + body?: never; } /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 20ae5278c9..f38b51a6c6 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -167,6 +167,17 @@ export const ListItemSchema = z.object({ disabled: z.boolean().optional().describe('Whether item is disabled'), onClick: handlerKeyRefusal('onClick', 'retired', 'Click handler'), content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Custom content'), + // RETIRED (objectui#9590) — mirrors `ListItem.body: never` (`../data-display.ts`). + // This object STRIPS undeclared keys, so leaving `body` undeclared would keep + // dropping it in silence; the refusal names `content` instead. + body: aliasKeyRefusal( + 'body', + 'content', + 'this list item', + '`list` draws each item from `content` — a string as-is, a node or node array through ' + + '`SchemaRenderer` (`packages/components/src/renderers/data-display/list.tsx`). The item-level ' + + '`body` fallback was retired by objectui#9590.', + ), }); /** @@ -187,8 +198,8 @@ export const ListSchema = BaseSchema.extend({ + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no error, no ' + 'warning, no element. ' + 'What it renders instead: `bind`, `items`, `ordered`, `title`, `wrapperClass`. ' - + 'The ITEM channel is a different key and stays live: each entry is drawn as ' - + '`item.content || renderChildren(item.body)`, a read filed under ListItem and not under this node. ' + + 'The ITEM channel is a different key and stays live: each entry is drawn from its `content` ' + + '(`renderChildren(item.content)`), a read filed under ListItem and not under this node. ' + '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); ' + 're-derive with `pnpm check:registry-bare-names --table` (objectui#9264).', ), @@ -199,8 +210,8 @@ export const ListSchema = BaseSchema.extend({ + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no error, no ' + 'warning, no element. ' + 'What it renders instead: `bind`, `items`, `ordered`, `title`, `wrapperClass`. ' - + 'The ITEM channel is a different key and stays live: each entry is drawn as ' - + '`item.content || renderChildren(item.body)`, a read filed under ListItem and not under this node. ' + + 'The ITEM channel is a different key and stays live: each entry is drawn from its `content` ' + + '(`renderChildren(item.content)`), a read filed under ListItem and not under this node. ' + '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); ' + 're-derive with `pnpm check:registry-bare-names --table` (objectui#9264).', ), diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index a69b99b43d..f1e3a29d23 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -437,6 +437,16 @@ export const TabItemSchema = z.object({ icon: z.string().optional().describe('Tab icon'), disabled: z.boolean().optional().describe('Whether tab is disabled'), content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).describe('Tab content'), + // RETIRED (objectui#9590) — mirrors `TabItem.body: never` (`../layout.ts`). + // This object STRIPS undeclared keys, so leaving `body` undeclared would keep + // dropping it in silence beside a `content`; the refusal names `content` instead. + body: aliasKeyRefusal( + 'body', + 'content', + 'this tab item', + '`tabs` draws each panel from `content` (`packages/components/src/renderers/layout/tabs.tsx`). ' + + 'The item-level `body` fallback was retired by objectui#9590.', + ), }); /** diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 029936eaba..8d57c521d3 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -517,6 +517,14 @@ describe('the `body` consumers the ruling does not enumerate', () => { // `items[].body`; `tabs` items and the `dashboard` widget key are recorded // on it by comment 5733850974, so this pointer resolves to a record that // actually carries the two shapes named here. + // ⚠️ Dated note, 2026-09-25 — all three subjects have since moved, and the + // pointer above is scoped to when it was written. objectui#9590 retired both + // item READS (`list.tsx` and `tabs.tsx` draw `content` only) and made both + // item faces refuse `body` by name; the `dashboard` widget key stopped being + // taught under objectui#8290. ⇒ the live tree derives NO item reader, so an + // item-carried `body` now fails C2 here and the item-carried assertions in + // this block are vacuous for that second reason too; the planted + // DIFFERENTIAL fixture is what keeps the shape reachable — objectui#9590. // ⚠️ VACUOUS SINCE objectui#9941 — the item-carried set is EMPTY, so this // loop body never runs and asserts nothing. Kept, ⛔ not deleted: it is the // disposition claim that fires the moment an item-carried producer returns. diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md index bd2bf7c2fe..262706fa37 100644 --- a/skills/objectui/guides/schema-expressions.md +++ b/skills/objectui/guides/schema-expressions.md @@ -434,12 +434,12 @@ descriptor: | Entry shape | Rendered as | |---|---| | `"Ada"` (a plain string) | the string | -| `{ "content": … }` | `content` verbatim — **not** expression-evaluated | -| `{ "body": node }` or `{ "body": [node, …] }` | rendered through `SchemaRenderer` | +| `{ "content": "Ada" }` (a string) | the string verbatim — **not** expression-evaluated | +| `{ "content": node }` or `{ "content": [node, …] }` | rendered through `SchemaRenderer` | | `{ "className": … }` | class on the `
  • ` | | anything else — e.g. a record `{ "name": "Ada" }` | an **empty `
  • `** | -`content` wins over `body` when both are present. The last row is the trap this +`body` on an item is refused; use `content`. The last row is the trap this section exists to close: binding `list` to ordinary records produces one empty `
  • ` per record — the right number of bullets, no text in any of them. @@ -460,16 +460,16 @@ section exists to close: binding `list` to ordinary records produces one empty { "type": "list", "bind": "rows" } ``` -Only `body` entries go back through `SchemaRenderer`, so they are the one place -inside a list where expressions are evaluated at all — against the host scope, -never against a current element: +Only a node-valued `content` goes back through `SchemaRenderer`, so it is the one +place inside a list where expressions are evaluated at all — against the host +scope, never against a current element: ```jsonc -// ✅ `${data.*}` works inside `body`; there is still no `${item.*}` +// ✅ `${data.*}` works inside a node-valued `content`; there is still no `${item.*}` { "type": "list", - "items": [{ "body": { "type": "text", "content": "Owner: ${data.team.owner}" } }] + "items": [{ "content": { "type": "text", "content": "Owner: ${data.team.owner}" } }] } ``` @@ -500,7 +500,7 @@ working routes: 1. **Expand in the host.** Map your records to nodes *before* handing the schema to `SchemaRenderer` — the host has the full array and can build one node per record with real string interpolation. -2. **Feed data-as-nodes.** Shape the data as list entries (`content` / `body`) +2. **Feed data-as-nodes.** Shape the data as list entries (`content`) and let `items` or `bind` render it, per the table above. ### The per-row scope that does exist