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
23 changes: 23 additions & 0 deletions .changeset/9941-tabs-default-items-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/components': patch
---

The shipped `ui:tabs` registration's `defaultProps.items` now spell `content`, the
child key the published `TabItemSchema` declares, instead of the undeclared `body`
(objectui#9941).

All three seeded items spelled `body` and omitted `content`, so the default an
author drops on a canvas was refused by the validator this repository publishes
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
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/**
* 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.
*/

/**
* The shipped `ui:tabs` registration's `defaultProps.items` satisfy the
* published `TabItemSchema` (objectui#9941).
*
* ## The defect this pin was built around
*
* `TabItemSchema` (`packages/types/src/zod/layout.zod.ts`, cited by SYMBOL —
* objectui#8875) declares `content` with **no** `.optional()` — required — and
* declares **no `body` member at all**. The registration seeded three items
* that spelled `body` and omitted `content`, so the default an author drops on
* a canvas was refused by the validator this repository publishes for it:
*
* 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.
*
* ## Why this pin parses instead of reading the spelling
*
* A test asserting the three items spell `content` is satisfied forever by a
* copy-paste of the literal; it re-states the fix instead of checking it. This
* one feeds whatever the registry actually carries through the published schema,
* so it fails the day anyone re-spells a member, adds a fourth item that omits
* `content`, or moves the seed — and it fails for the same reason the card was
* filed, not for a reason a reader has to reconstruct.
*
* Both faces are read off live artifacts: the seed off `ComponentRegistry` (the
* object a designer palette consumes), the contract off `@object-ui/types/zod`
* (the object a validator consumes). Nothing below is a copy of either.
*
* ## Which rows discriminate, and which are controls
*
* ⚠️ The `controlB` rows are green in BOTH worlds BY DESIGN — that is the whole
* claim of this card. The repair is at the parse, so a row that moved with it
* would be evidence the repair was visual after all. The rows that discriminate
* are the three subject rows; `controlA` and `controlB` exist to prove the
* instrument can fail and that the UI did not follow, respectively.
*
* ## The instrument's two failure modes, each with its own control
*
* 1. **A parse over nothing is green.** An empty (or missing) `items` array
* makes `every item parses` vacuously true, so the seed's presence and
* non-emptiness are asserted first, as their own rows.
* 2. **A parse that cannot refuse anything is green.** `controlA` feeds the
* pre-fix item — the same objects with `content` re-spelled back to `body` —
* to the same call and requires a refusal that names `content`. If that row
* goes green the subject rows below measure nothing.
*
* ## ⛔ 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.
* - ⛔ 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
* than by ruling is the trap the sibling pin in this directory
* (`registration-defaults-match-renderer-8229.test.ts`) documents at length.
*/

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';
import { TabItemSchema, TabsSchema } from '@object-ui/types/zod';

afterEach(() => cleanup());

type Item = Record<string, unknown>;

const meta = () => ComponentRegistry.getMeta('tabs', 'ui');
const seed = () => (meta()?.defaultProps ?? {}) as Record<string, unknown>;
const seededItems = () => (seed().items ?? []) as Item[];

/**
* The pre-fix spelling, rebuilt from whatever the registry carries today: the
* item's child list, wherever it is spelled, moved onto `body` with `content`
* gone. Reading BOTH spellings keeps this a faithful inverse under ablation —
* re-spell the seed back to `body` and this returns the seed unchanged, so the
* control rows stay controls and only the subject rows move.
*/
const preFix = (items: Item[]): Item[] =>
items.map(({ content, body, ...rest }) => ({ ...rest, body: content ?? body }));

/** Render the registration seed as a `tabs` node and return what it paints. */
function renderSeed(items: Item[]): { text: string; html: string } {
const C = ComponentRegistry.get('tabs', 'ui');
if (!C) throw new Error('no renderer registered for ui:tabs');
render(<C schema={{ type: 'tabs', ...seed(), items }} />);
// Radix mints a fresh id per mount, so two renders of identical content
// differ in `id`/`aria-controls`/`aria-labelledby` and nowhere else. Blank
// those three out; everything that carries meaning survives.
const html = (document.body.innerHTML ?? '').replace(
/(id|aria-controls|aria-labelledby)="[^"]*"/g,
'$1="#"',
);
return { text: document.body.textContent ?? '', html };
}

describe('`ui:tabs` defaultProps.items satisfy the published TabItemSchema (objectui#9941)', () => {
describe('the seed is really there — ⛔ a parse over nothing is vacuously green', () => {
it('the registration is registered and carries a defaultProps seed', () => {
expect(meta(), 'ui:tabs is not registered — every row below is vacuous').toBeDefined();
expect(meta()?.defaultProps).toBeDefined();
});

it('the seed carries a non-empty `items` array', () => {
expect(Array.isArray(seed().items)).toBe(true);
expect(seededItems().length).toBeGreaterThan(0);
});
});

describe('controlA — the instrument can FAIL', () => {
it('the pre-fix item is refused, and the refusal names `content`', () => {
// The exact reading objectui#9941 was filed on, re-taken through the same
// call the subject rows use. Structured fields only — ⛔ no message prose.
const refused = preFix(seededItems()).map((item) => TabItemSchema.safeParse(item));
expect(refused.length).toBeGreaterThan(0);
for (const r of refused) {
expect(r.success).toBe(false);
if (r.success) continue;
expect(r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }))).toContainEqual({
code: 'invalid_type',
path: 'content',
});
const missing = r.error.issues.find((i) => i.path.join('.') === 'content');
expect((missing as { expected?: string } | undefined)?.expected).toBe('nonoptional');
}
});

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({
value: 'probe',
label: 'probe',
content: [{ type: 'text', content: 'kept' }],
body: [{ type: 'text', content: 'dropped' }],
});
expect(Object.keys(parsed).sort()).toEqual(['content', 'label', 'value']);
expect(parsed).not.toHaveProperty('body');
});
});

describe('the subject', () => {
it('every seeded item parses green against `TabItemSchema`', () => {
const failures = seededItems()
.map((item, i) => ({ i, r: TabItemSchema.safeParse(item) }))
.filter(({ r }) => !r.success)
.map(({ i, r }) =>
`items[${i}]: ${(r as { error: { issues: { code: string; path: PropertyKey[] }[] } }).error.issues
.map((issue) => `${issue.code} [${issue.path.join('.')}]`)
.join(', ')}`,
);
expect(
failures,
'the shipped default is the metadata an author drops on a canvas — it has to ' +
'satisfy the schema this repository publishes for it. `TabItem`\'s child key is ' +
'`content` (⛔ not `body`, ⛔ not `children`); spell the seed that way rather than ' +
'widening `TabItemSchema`, which would grow an already-published accept set and ' +
'pre-empt objectui#9871.',
).toEqual([]);
});

it('the whole registration seed parses as a `tabs` node', () => {
// One step above the item face: what the designer writes out is the node,
// not a loose item. Green here is the end-to-end statement of the repair.
const r = TabsSchema.safeParse({ type: 'tabs', ...seed() });
expect(r.success, JSON.stringify(r.success ? [] : r.error.issues, null, 1)).toBe(true);
});

it('each seeded item keeps a child list after the parse', () => {
// Green-because-empty guard on the row above: an item whose `content` was
// an empty array would parse and carry nothing.
for (const item of seededItems()) {
const parsed = TabItemSchema.parse(item) as { content: unknown };
expect(parsed.content).toBeDefined();
expect(Array.isArray(parsed.content) ? (parsed.content as unknown[]).length : 1).toBeGreaterThan(0);
}
});
});

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.
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');
});
});
});
6 changes: 3 additions & 3 deletions packages/components/src/renderers/layout/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ ComponentRegistry.register('tabs', TabsRenderer,
defaultProps: {
defaultValue: 'tab1',
items: [
{ label: 'Tab 1', value: 'tab1', body: [{ type: 'text', content: 'Content for Tab 1' }] },
{ label: 'Tab 2', value: 'tab2', body: [{ type: 'text', content: 'Content for Tab 2' }] },
{ label: 'Tab 3', value: 'tab3', body: [{ type: 'text', content: 'Content for Tab 3' }] }
{ label: 'Tab 1', value: 'tab1', content: [{ type: 'text', content: 'Content for Tab 1' }] },
{ label: 'Tab 2', value: 'tab2', content: [{ type: 'text', content: 'Content for Tab 2' }] },
{ label: 'Tab 3', value: 'tab3', content: [{ type: 'text', content: 'Content for Tab 3' }] }
],
className: 'w-full'
}
Expand Down
50 changes: 39 additions & 11 deletions scripts/__tests__/body-dialect-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,23 @@ describe('the `body` consumers the ruling does not enumerate', () => {
// rather than reasoned, both legs, against the built parser: a `card` node
// carrying `body` draws `unknown-prop: <card> has no prop "body" — the
// child-list key is "children"`, and a `tabs` item carrying the same key
// draws ZERO diagnostics. ⇒ an empty table is objectui#9590's finish line,
// ⛔ not step 4's gate. ⛔ Do not delete the claim to make the block green.
// draws ZERO diagnostics.
//
// ⭐ RE-POINTED at objectui#9941, ⛔ not deleted — the assertion below
// carried its own instruction to do exactly that, in these terms. The table
// emptied when the three `tabs.tsx` `defaultProps` items were respelled to
// `content`, the key `TabItemSchema` declares required while declaring no
// `body`: ⛔ a CONFORMANCE fix, ⛔ not a dialect migration.
//
// ⭐ And the re-pointed claim is SCOPED: the table is empty **under
// objectui#9871's EMISSION criterion**. ⛔ It is NOT a claim that the
// dialect has left the tree, and ⛔ NOT objectui#9590's finish line.
// Measured across the same change: `failedC2` held at 313 and
// `unclassified` at 5, so 318 rows outside what that criterion scores as
// emission did not move — and objectui#9989 is a published doc still
// TEACHING the spelling. ⭐ Emitted and taught are different verbs, and this
// scan reads only the first. Whether the finish line is reached is
// objectui#9590's to judge on its own record.
const producerScan = scan(REPO_ROOT);

// Two lit controls, because an empty table is the assertion's own shape: a
Expand All @@ -391,20 +406,30 @@ describe('the `body` consumers the ruling does not enumerate', () => {
const producers = producersOf(producerScan.hits, producerScan.readers);
expect(
producers.length,
'the producer table is EMPTY — under objectui#9871\'s criterion nothing in shipped source ' +
'emits the dialect any more. ⚠️ That is objectui#9590\'s finish line, ⛔ NOT step 4\'s gate: ' +
'step 4 landed in objectui#6771 with this table non-empty, because everything left in it ' +
'is item-carried and outside the ruled family. ' +
'Re-point this block and say so in those terms; ⛔ do not delete the claim.'
).toBeGreaterThan(0);
'a producer is BACK — shipped source emits the dialect again under objectui#9871\'s ' +
'EMISSION criterion, and it has not since objectui#9941. ⛔ Do not relax this to a ' +
'range: name the site and its carrier, and re-read the criterion before deciding ' +
'what a non-zero means here.'
).toBe(0);

// ⭐ The two SHAPES the census structurally cannot reach, asserted as
// shapes rather than as paths: a producer arriving in a file nobody has
// named joins `producers` with no list to extend.
// named joins `producers` with no list to extend — that property is what
// this block holds, and it is unaffected by the occupancy being zero.
//
// ⚠️ B1's LIVE occupancy went to zero at objectui#9941; the three `tabs.tsx`
// `defaultProps` items were its last subjects. ⭐ The SHAPE is ⛔ not
// unreachable and this block is ⛔ not blind:
// `scripts/__tests__/body-dialect-producer-scan.test.ts`'s DIFFERENTIAL test
// plants `ITEM_PRODUCER` in a synthetic root and still asserts carrier
// ['item'], line 6, channel 'default-props' — it passed in the same run that
// first reported this red. That fixture is what makes standing down the
// live-tree control here safe rather than blind.
expect(
producers.filter((hit: { carrier: string }) => hit.carrier === 'item').length,
'no item-carried producer — the B1 shape (a `body` on an object with no `type`)'
).toBeGreaterThan(0);
'an item-carried producer is BACK — the B1 shape (a `body` on an object with no `type`) ' +
'is live in shipped source again. ⛔ Not a licence to relax this; name the site.'
).toBe(0);
// ⚠️ B2 IS INVERTED BY THIS CARD, and the inversion is what the merge with
// `main` actually found rather than a tidy-up. objectui#9871 asserted a
// string-carried producer EXISTED, and its only subjects were the three VS
Expand Down Expand Up @@ -484,6 +509,9 @@ describe('the `body` consumers the ruling does not enumerate', () => {
// `list`'s `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.
// ⚠️ 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.
for (const hit of producers.filter((h: { carrier: string }) => h.carrier === 'item')) {
expect(hit.disposition).toBe('unruled:item-carrier');
}
Expand Down
Loading
Loading