diff --git a/.changeset/9941-tabs-default-items-content.md b/.changeset/9941-tabs-default-items-content.md new file mode 100644 index 0000000000..83a8346a0b --- /dev/null +++ b/.changeset/9941-tabs-default-items-content.md @@ -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. 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 new file mode 100644 index 0000000000..36dbff3140 --- /dev/null +++ b/packages/components/src/renderers/layout/__tests__/tabs-default-items-parse-9941.test.tsx @@ -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; + +const meta = () => ComponentRegistry.getMeta('tabs', 'ui'); +const seed = () => (meta()?.defaultProps ?? {}) as Record; +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(); + // 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'); + }); + }); +}); diff --git a/packages/components/src/renderers/layout/tabs.tsx b/packages/components/src/renderers/layout/tabs.tsx index 502350f2bc..cfa19819de 100644 --- a/packages/components/src/renderers/layout/tabs.tsx +++ b/packages/components/src/renderers/layout/tabs.tsx @@ -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' } diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 86d3c9dd7f..894fd5c7a7 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -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: 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 @@ -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 @@ -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'); } diff --git a/scripts/__tests__/body-dialect-producer-scan.test.ts b/scripts/__tests__/body-dialect-producer-scan.test.ts index 00ed54e894..bb4487aaf4 100644 --- a/scripts/__tests__/body-dialect-producer-scan.test.ts +++ b/scripts/__tests__/body-dialect-producer-scan.test.ts @@ -553,8 +553,47 @@ describe('the LIVE tree, read through the criterion', () => { // ⭐ Asserted as SHAPES, not as a count and not as a list of paths: a // producer arriving in a file nobody named appears in `producers` without // anyone extending anything, which is the whole point of this card. - expect(producers.filter((p: { carrier: string }) => p.carrier === 'item').length).toBeGreaterThan(0); - expect(files.size).toBeGreaterThan(0); + // + // ⭐ RE-POINTED at objectui#9941, ⛔ not deleted — this block's own former + // message asked for exactly that when the table emptied. What it now says, + // and it says ⛔ nothing beyond it: + // + // 1. the table is EMPTY **under objectui#9871's EMISSION criterion** + // (C1 + C2). ⛔ That is not "the dialect has left the tree". + // 2. its last subject was the three `defaultProps` items in + // `packages/components/src/renderers/layout/tabs.tsx`, respelled + // `body:` -> `content:` under objectui#9941 because `TabItemSchema` + // declares `content` required and declares no `body` — ⛔ a + // CONFORMANCE fix, ⛔ not a dialect migration. The two are not one act. + // 3. ⛔ THIS IS NOT objectui#9590'S FINISH LINE AND THIS BLOCK DOES NOT + // CLAIM IT IS. Measured across that same change: `failedC2` 313 -> 313 + // and `unclassified` 5 -> 5, both identical — 318 rows sit outside + // what this criterion scores as emission and not one of them moved — + // 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. + // 4. ⭐ the instrument is ⛔ NOT blind, and that is what makes standing + // down a live-tree control safe rather than blind: the DIFFERENTIAL + // test at the top of this file 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 these + // reds. The SHAPE stays reachable; only its live occupancy is zero. + // + // ⛔ Do not relax either of these to a range — the file's own idiom for a + // shape that has emptied is `toBe(0)` plus a message naming what a non-zero + // means (see the string-carried assertion below). + expect( + producers.filter((p: { carrier: string }) => p.carrier === 'item').length, + 'an item-carried producer is BACK — the B1 shape (a `body` on an object with no `type`) ' + + 'is live in shipped source again, and it has not been since objectui#9941. ' + + '⛔ Not a licence to relax this: name the site and its carrier.', + ).toBe(0); + expect( + files.size, + 'a producer FILE is back, so the empty-table reading above no longer holds. ' + + '⛔ Re-read that reading before changing anything here.', + ).toBe(0); // ⚠️ B2 INVERTED BY objectui#6771, and this is the SECOND copy of the claim // — the census block carries the first. ⭐ Neither file was in the merge's @@ -580,6 +619,14 @@ describe('the LIVE tree, read through the criterion', () => { 'lit control — no literal-carried hit passes C2, so the zero below would measure C2 ' + 'rather than the emission channel it claims to measure', ).toBeGreaterThan(0); + // ⚠️ VACUOUS SINCE objectui#9941, and said out loud rather than left to be + // discovered by the next reader. `producers` is EMPTY, so every filter over + // it is 0 and this passes without searching for anything — the phantom-check + // shape. It is kept, ⛔ not deleted, because it is the claim that fires the + // moment any producer returns; until one does it measures nothing, and the + // two lit controls directly above are what keep B2's own sight under test in + // the meantime. ⇒ they are ⛔ not decoration now, they are the only live + // half of this paragraph. expect( producers.filter((p: { source: string }) => p.source !== 'code-key').length, 'a string-carried producer is BACK — objectui#6771 step 5 migrated the last of them. ' + @@ -601,15 +648,30 @@ describe('the LIVE tree, read through the criterion', () => { // `unknown-prop: has no prop "body" — the child-list key is // "children"`, and a `tabs` ITEM carrying the same key draws ZERO // diagnostics. ⇒ step 4 refuses nothing the platform still ships. - // An empty table is objectui#9590's finish line, ⛔ not step 4's gate. - // ⛔ When this reds because the table emptied, that is still the HANDOFF — - // say so here in those terms rather than deleting the assertion. + // + // ⭐ RE-POINTED at objectui#9941, ⛔ not deleted — this assertion's own + // former message asked for that, in these terms. The table emptied when the + // three `tabs.tsx` `defaultProps` items were respelled to `content` to + // satisfy `TabItemSchema`, which declares `content` required and declares no + // `body`: ⛔ a conformance fix, ⛔ not a dialect migration. + // + // ⛔ An empty table is ⛔ NOT asserted here to be objectui#9590's finish + // line. The claim is scoped to what was measured — empty UNDER + // objectui#9871's EMISSION criterion. Across the same change `failedC2` held + // at 313 and `unclassified` at 5, so 318 rows outside what this criterion + // scores as emission did not move, and objectui#9989 is a published doc + // still TEACHING the spelling. Whether the finish line is reached is + // objectui#9590's to judge on its own record. + // + // ⭐ Safe rather than blind: the DIFFERENTIAL test at the top of this file + // plants `ITEM_PRODUCER` in a synthetic root and still asserts carrier + // ['item'], line 6, channel 'default-props'. expect( producers.length, - 'the producer table is EMPTY — under this criterion nothing ships the dialect. That is ' + - 'objectui#9590\'s finish line, ⛔ NOT objectui#6771 step 4\'s gate: step 4 landed with ' + - 'this table non-empty and item-carried. Re-point this block, do not delete it.', - ).toBeGreaterThan(0); + 'a producer is BACK — under objectui#9871\'s EMISSION criterion shipped source emits the ' + + 'dialect again, 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 it means.', + ).toBe(0); // ⏱ Explicit — one tree-wide scan over 5,136 files, measured 4.0s here; the // CI shard is at least 1.9x slower (objectui#9871's timeout reading), and // the default 15s leaves too little room on a loaded shard. Same 60s and the