From 6cad675a63334c36cdd5e23fe704104f57d43dde Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 17:14:08 +0000 Subject: [PATCH 1/2] fix(types): tree-view mirror stops requiring the limb it reads third `TreeViewSchema` required `data`, which the renderer reads THIRD: const rawNodes = boundData || schema.nodes || schema.data || []; // packages/components/src/renderers/data-display/tree-view.tsx:105 The registration's own `inputs` and `defaultProps` spell it `nodes`, and the four `components-data-display-tree-view/*` catalog entries ARE those `defaultProps`, so `safeValidateSchema` refused every one of them while the renderer drew them correctly. Re-measured on origin/main at fe4e7a9e8: four refusals, and renders byte-identical under either spelling (28 / 28 / 12 / 34 elements, same tag census, same textContent SHA-256) -- objectui#6318's own triage test for "the schema was the wrong side". `data` becomes optional on the zod mirror and on the TypeScript twin in the same stroke. It stays DECLARED rather than deleted: BaseSchema already declares `data` as `z.any().optional()`, so removing the member would not reject the key -- it would admit it unvalidated while the renderer went on reading it. No refinement is added, unlike this card's object-map / object-gantt group: a tree-view with no data source now validates, and that admits no new rendering outcome, since `{ data: [] }` was already legal and already drew the same empty tree. `nodes` and `title` are objectui#6150's declarations and are untouched; that card declared the reads and said in as many words that relaxing `data` was a separate accept-set change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/6939-tree-view-nodes-mirror.md | 47 ++++ .../test/tree-view-nodes-mirror-6939.test.tsx | 200 ++++++++++++++++++ .../tree-view-data-optional-6939.test.ts | 192 +++++++++++++++++ packages/types/src/data-display.ts | 23 +- packages/types/src/zod/data-display.zod.ts | 6 +- 5 files changed, 459 insertions(+), 9 deletions(-) create mode 100644 .changeset/6939-tree-view-nodes-mirror.md create mode 100644 examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx create mode 100644 packages/types/src/__tests__/tree-view-data-optional-6939.test.ts diff --git a/.changeset/6939-tree-view-nodes-mirror.md b/.changeset/6939-tree-view-nodes-mirror.md new file mode 100644 index 0000000000..f0c88a28a0 --- /dev/null +++ b/.changeset/6939-tree-view-nodes-mirror.md @@ -0,0 +1,47 @@ +--- +'@object-ui/types': patch +--- + +Repair the `tree-view` mirror: `data` is optional, so the `nodes` spelling the +renderer reads FIRST is a legal document on its own (objectui#6939, maintainer +ruling recorded 2026-09-02 — this is one of the eight groups on that card, +dispatched as its own PR per the ruling). + +`TreeViewSchema` REQUIRED `data`, the limb the renderer reads THIRD: + + const rawNodes = boundData || schema.nodes || schema.data || []; + // packages/components/src/renderers/data-display/tree-view.tsx:105 + +The registration's own `inputs` and `defaultProps` spell it `nodes`, and the +four `components-data-display-tree-view/*` catalog entries ARE those +`defaultProps` — so `safeValidateSchema` refused every one of them +(`: Invalid input`) while the renderer drew them correctly. Re-measured on +`origin/main` at `fe4e7a9e8`: four refusals, and four renders that are +byte-identical under either spelling (28 / 28 / 12 / 34 elements, same tag +census, same `textContent` SHA-256). Identical output under the "correction" is +objectui#6318's own triage test for *the schema was the wrong side*. + +**This is a WIDENING, on both faces.** `data` goes from required to optional on +the mirror and on the TypeScript twin in the same stroke; nothing that validated +before validates less. A document authored on `data` — such as the tree-view +entry in `packages/types/examples/data-display-examples.json` — is untouched, +and both spellings together stay legal. Hence `patch`, matching the two sibling +groups of this card that have already landed. + +**`data` stays DECLARED rather than being deleted**, and the difference is +measured rather than assumed: `BaseSchema` already declares `data` +(`z.any().optional()`; `data?: any` on the TS face), so removing the member +would not reject the key — it would admit it *unvalidated* while the renderer +went on reading it. Optional-and-typed is the only shape in which `declared` and +`enforced` agree for a key that is still read. + +**No refinement was added**, deliberately, unlike this card's +`object-map` / `object-gantt` group. A tree-view carrying no data source at all +becomes legal here, and that admits no new rendering outcome: `{ data: [] }` was +already legal and already drew the same empty tree, so an "at least one of +`nodes` / `data` / `bind`" rule would forbid a spelling of an empty state the +contract already permits rather than buy a guarantee. + +`nodes` and `title` are objectui#6150's declarations and are unchanged; that +card declared the reads and said in as many words that relaxing `data` was a +separate accept-set change. This is that change. diff --git a/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx b/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx new file mode 100644 index 0000000000..9a6f562f49 --- /dev/null +++ b/examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx @@ -0,0 +1,200 @@ +/** + * 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#6939, the `tree-view` group — the RENDER half. The validator-side + * contract is pinned in + * `packages/types/src/__tests__/tree-view-data-optional-6939.test.ts`. + * + * `TreeViewSchema` REQUIRED `data`, the limb the renderer reads THIRD + * (`boundData || schema.nodes || schema.data || []`, + * `renderers/data-display/tree-view.tsx:105`), so all four catalog entries — + * which author `nodes`, the spelling the registration's own `inputs` and + * `defaultProps` use, and which ARE those `defaultProps` — were refused by + * `safeValidateSchema` while drawing correctly. + * + * ## Why the render half is the discriminating half + * + * From objectui#6318's triage: a "correction" that renders identically proves + * the SCHEMA was wrong, not the fixture. So a repair on the schema side has to + * clear the mirror image of that bar — the validator's verdict must change and + * the renderer's output must NOT. `PRE_REPAIR` was measured on `origin/main` at + * `fe4e7a9e8`, BEFORE either face was touched, through THIS file's harness. + * + * ⚠️ The card's table reports "identical — 14 elements" for this row. The + * identity reproduces exactly (all four tiles, element count, tag census and a + * SHA-256 of `textContent`); the absolute 14 does NOT reproduce in this + * harness, which measures 28 / 28 / 12 / 34. Element counts are harness-bound — + * the docs-gallery harness (`catalog-gallery-render.test.tsx`, provider plus + * `SidebarProvider` plus a padded wrapper) gives different absolutes for the + * same tile — so identity WITHIN one harness is the claim that discriminates, + * and the number is recorded here rather than carried over from the card. + * + * Three readings per tile, because a count alone cannot tell a swapped element + * from an equal one: element count, a tag census, and the text (literally, plus + * a SHA-256 of it). + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { createHash } from 'node:crypto'; +import '@object-ui/components'; +import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; +import { TreeViewSchema, safeValidateSchema } from '@object-ui/types/zod'; +import { getExample } from '../src/index.js'; + +const IDS = [ + 'components-data-display-tree-view/deep-nesting', + 'components-data-display-tree-view/file-tree', + 'components-data-display-tree-view/org-chart', + 'components-data-display-tree-view/sidebar-navigation', +] as const; + +/** + * Measured on `origin/main` @ `fe4e7a9e8` through `measure()` below, both faces + * untouched. Every one of the four reported `: Invalid input` from + * `safeValidateSchema` at that commit, and every one drew exactly this. + */ +const PRE_REPAIR: Record<(typeof IDS)[number], { + elements: number; + tags: Record; + text: string; + sha256: string; +}> = { + 'components-data-display-tree-view/deep-nesting': { + elements: 28, + tags: { DIV: 10, H3: 1, BUTTON: 2, svg: 5, path: 6, SPAN: 4 }, + text: 'Project Structuresrcpublicpackage.json', + sha256: 'e55f4d288401013fd1d6d5f1045e77eba31b07c9a62656efdcc94945e7503167', + }, + 'components-data-display-tree-view/file-tree': { + elements: 28, + tags: { DIV: 10, H3: 1, BUTTON: 2, svg: 5, path: 6, SPAN: 4 }, + text: 'File ExplorerDocumentsPhotosREADME.md', + sha256: '3f2f5a7468194a1af717c2c798a3dd4f9421df445b6c36dae9748eb83639d84a', + }, + 'components-data-display-tree-view/org-chart': { + elements: 12, + tags: { DIV: 5, H3: 1, BUTTON: 1, svg: 2, path: 2, SPAN: 1 }, + text: 'OrganizationCEO', + sha256: '45f07e1527f23fbd805de7c378192a8fc79d82641d0e98fc35e745d721a17985', + }, + 'components-data-display-tree-view/sidebar-navigation': { + elements: 34, + tags: { DIV: 13, H3: 1, SPAN: 6, svg: 6, path: 6, BUTTON: 2 }, + text: 'NavigationDashboardProductsOrdersSettings', + sha256: '6126579fe6fca4c4d9c5e25d1ca32f8d3967f2ec1fd1f88bcafa2d8c3f399d01', + }, +}; + +/** Render one entry the way the docs gallery does and measure what it drew. */ +function measure(schema: unknown) { + const { container, unmount } = render( + , + ); + const nodes = Array.from(container.querySelectorAll('*')); + const text = container.textContent ?? ''; + const out = { + elements: nodes.length, + tags: nodes.reduce>((h, el) => ((h[el.tagName] = (h[el.tagName] ?? 0) + 1), h), {}), + text, + sha256: createHash('sha256').update(text).digest('hex'), + }; + unmount(); + return out; +} + +/** Report the issues rather than `false`, so a red run says what broke. */ +function reasons(schema: unknown): string[] { + const r = safeValidateSchema(schema); + return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`); +} + +/** The fixture as authored, and the "correction" objectui#6318's triage asks about. */ +function asAuthored(id: (typeof IDS)[number]) { + return getExample(id).schema as Record; +} +function asDataSpelling(id: (typeof IDS)[number]) { + const { nodes, ...rest } = asAuthored(id); + return { ...rest, data: nodes }; +} + +describe('objectui#6939 — the four tree-view entries the mirror refused now validate', () => { + it.each(IDS)('%s validates under safeValidateSchema', (id) => { + expect(reasons(asAuthored(id))).toEqual([]); + }); +}); + +describe('objectui#6939 — and the repair moved the validator, not the renderer', () => { + it.each(IDS)('%s renders exactly what it rendered before', (id) => { + const after = measure(asAuthored(id)); + const before = PRE_REPAIR[id]; + expect(after.elements).toBe(before.elements); + expect(after.tags).toEqual(before.tags); + expect(after.text).toBe(before.text); + expect(after.sha256).toBe(before.sha256); + }); + + it.each(IDS)('%s anti-vacuity: the tile drew its AUTHORED tree, not an empty box', (id) => { + // A tile that renders nothing — or the error boundary — satisfies + // "identical" trivially. The authored title and every root label on screen + // prove the nodes reached the renderer through `nodes`, the spelling the + // mirror refused. + const schema = asAuthored(id) as { title: string; nodes: { label: string }[] }; + const m = measure(schema); + expect(m.elements).toBeGreaterThan(10); + expect(m.text.trim().length).toBeGreaterThan(0); + expect(m.text).not.toContain('failed to render'); + expect(m.text).toContain(schema.title); + for (const node of schema.nodes) expect(m.text).toContain(node.label); + }); +}); + +describe('objectui#6939 — the fixtures were the side that was right', () => { + it.each(IDS)('%s: "correcting" it to `data` changes no pixel', (id) => { + // The card's own discriminator, re-measured here rather than quoted: the + // spelling swap moves nothing, so the schema was the wrong side. Contrast + // the same probe on the sibling groups, where the "correction" emptied the + // board or blanked the tile. + const authored = measure(asAuthored(id)); + const corrected = measure(asDataSpelling(id)); + expect(corrected.elements).toBe(authored.elements); + expect(corrected.text).toBe(authored.text); + expect(corrected.sha256).toBe(authored.sha256); + }); + + it.each(IDS)('%s stays on the spelling its renderer reads FIRST', (id) => { + const schema = asAuthored(id); + expect(schema.nodes).toBeDefined(); + expect('data' in schema).toBe(false); + }); + + it('both spellings validate — the accept set widened, it did not move', () => { + // ⛔ Do NOT "repair" a future red here by migrating the fixtures to `data`. + // The renderer reads `nodes` first and the registration's `defaultProps` + // spell it `nodes`; the fixtures ARE those defaults. + for (const id of IDS) { + expect(reasons(asAuthored(id))).toEqual([]); + expect(reasons(asDataSpelling(id))).toEqual([]); + } + }); + + it('the keys are DECLARED, not passthrough holes', () => { + // Counter-probes on keys the mirror declares; an unknown key proves nothing + // here, because `BaseSchema` is `.passthrough()`. + const shape = (TreeViewSchema as unknown as { shape: Record }).shape; + expect(Object.keys(shape)).toEqual(expect.arrayContaining(['nodes', 'title', 'data'])); + expect(TreeViewSchema.safeParse({ type: 'tree-view', nodes: 'not-an-array' }).success).toBe(false); + expect(TreeViewSchema.safeParse({ type: 'tree-view', data: 'not-an-array' }).success).toBe(false); + // …and a good shape still passes, so the two above are not failing for some + // unrelated reason. ⚠️ The carrier carries BOTH spellings on purpose: it is + // legal with or without this card, so this control cannot redden for the + // relaxation it is controlling for. (That the authored, `nodes`-only + // fixtures parse is the first describe block's claim, not this one's.) + expect(TreeViewSchema.safeParse({ + type: 'tree-view', title: 'File Explorer', data: [], nodes: [{ id: '1', label: 'Documents' }], + }).success).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts new file mode 100644 index 0000000000..5db922cd28 --- /dev/null +++ b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts @@ -0,0 +1,192 @@ +/** + * 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#6939, the `tree-view` group — the VALIDATOR half. The render half + * is `examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx`. + * + * ## The defect + * + * `TreeViewSchema` REQUIRED `data`, the limb the renderer reads THIRD: + * + * const rawNodes = boundData || schema.nodes || schema.data || []; + * // packages/components/src/renderers/data-display/tree-view.tsx:105 + * + * The registration's own `inputs` and `defaultProps` spell it `nodes`, and the + * four `components-data-display-tree-view/*` catalog entries ARE those + * `defaultProps` — so every one of them was refused by `safeValidateSchema` + * (`: Invalid input`, the union's top-level issue) while drawing correctly. + * Measured on `origin/main` at `fe4e7a9e8`, mirror untouched: all four refused, + * and all four rendered byte-identically under the `data` spelling. + * + * ## What objectui#6150 did and did NOT do + * + * #6150 DECLARED `nodes` and `title` — both faces, both keys, and its pins in + * `undeclared-but-consumed-keys-6150.test.ts` still hold. It deliberately + * stopped there and wrote so: relaxing `data` is an accept-set change and a + * separate ruling. This is that change; `nodes` and `title` are untouched here. + * + * ## Why `data` stays DECLARED instead of being deleted + * + * Deleting the member is the intuitive reading of "the renderer prefers + * `nodes`", and it is the wrong one — measured, not assumed. `BaseSchema` + * already declares `data` (`z.any().optional()`; `data?: any` on the TS face), + * so a `TreeViewSchema` without its own `data` member does not REJECT the key: + * it admits it unvalidated, while the renderer goes on reading it. Enforcement + * would be traded away for nothing. `assertion data is still VALIDATED` below + * is the pin that makes the difference visible — it is the assertion that turns + * green-to-red if a later sweep deletes the member. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { TreeViewSchema } from '../zod/data-display.zod'; +import { BaseSchema } from '../zod/base.zod'; +import type { TreeNode, TreeViewSchema as TsTreeViewSchema } from '../data-display'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const READER = 'packages/components/src/renderers/data-display/tree-view.tsx'; +const READ_TEXT = 'boundData || schema.nodes || schema.data || []'; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** + * `data` is OPTIONAL and still `TreeNode[]` — one pin, biting in both + * directions that matter: + * + * - required again -> `TreeNode[]` is not `TreeNode[] | undefined` -> red + * - member DELETED -> falls through `BaseSchema`'s `[key: string]: any` + * to `any`, and `Equal< any, … >` is false -> red + */ +export type _TreeDataIsOptionalTreeNodes = + Expect< Equal< TsTreeViewSchema['data'], TreeNode[] | undefined > >; + +/** `nodes` and `title` are objectui#6150's and are unchanged by this card. */ +export type _TreeNodesStillTreeNodes = + Expect< Equal< NonNullable< TsTreeViewSchema['nodes'] >, TreeNode[] > >; +export type _TreeTitleStillString = + Expect< Equal< NonNullable< TsTreeViewSchema['title'] >, string > >; + +/** + * The document the four catalog entries author. It did not compile before this + * card — `data` was a required member — and an index signature cannot rescue a + * MISSING required key, so this annotation is a real compile-time pin. + */ +export const NODES_ONLY_DOCUMENT: TsTreeViewSchema = { + type: 'tree-view', + title: 'File Explorer', + nodes: [{ id: '1', label: 'Documents' }], +}; + +/* ── Runtime pins ─────────────────────────────────────────────────────────── */ + +const ROOT = { type: 'tree-view' } as const; +const NODES = [{ id: '1', label: 'Documents' }]; +/** An undeclared key, so "refused" below can be told from "strict object". */ +const SENTINEL = 'undeclaredTreeKey6939'; + +describe('objectui#6939 — a `nodes`-only tree-view is a legal document', () => { + it('the spelling the renderer reads FIRST now parses on its own', () => { + const r = TreeViewSchema.safeParse({ ...ROOT, nodes: NODES }); + expect(r.success, JSON.stringify(r.success ? [] : r.error.issues)).toBe(true); + }); + + it('…and the value SURVIVES the parse, rather than being stripped', () => { + const r = TreeViewSchema.safeParse({ ...ROOT, title: 'File Explorer', nodes: NODES }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.nodes).toEqual(NODES); + expect(r.data.title).toBe('File Explorer'); + } + }); + + it('the accept set only WIDENED — every `data` document that parsed still parses', () => { + // The patch reasoning. Nothing that validated before this change may stop + // validating: `packages/types/examples/data-display-examples.json` authors + // its tree-view on `data`, and the renderer still reads that limb third. + expect(TreeViewSchema.safeParse({ ...ROOT, data: NODES }).success).toBe(true); + expect(TreeViewSchema.safeParse({ ...ROOT, data: NODES, nodes: NODES }).success).toBe(true); + expect(TreeViewSchema.safeParse({ ...ROOT, data: [] }).success).toBe(true); + }); + + it('a tree-view with NO data source at all is legal, and that admits nothing new', () => { + // Stated rather than hidden: this is the one document class the relaxation + // adds. It is not a new rendering outcome — `{ data: [] }` was ALREADY + // legal and already drew the same empty tree, so no refinement of the + // `at least one of` shape (the one objectui#6939's object-map / object-gantt + // group added) would be buying a guarantee here. It would only forbid a + // spelling of the empty state that the contract already permits. + expect(TreeViewSchema.safeParse(ROOT).success).toBe(true); + }); +}); + +describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED', () => { + it('a wrong-typed `data` is refused AT the key', () => { + // ⛔ This is the assertion that reddens if a later sweep DELETES the member + // instead of relaxing it. Deleting is not a rejection: `BaseSchema.data` is + // `z.any().optional()`, so the key would be admitted unvalidated. + const r = TreeViewSchema.safeParse({ ...ROOT, data: 'not-an-array' }); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data'); + }); + + it('element-level enforcement survives the relaxation', () => { + const r = TreeViewSchema.safeParse({ ...ROOT, data: [{ label: 'no id' }] }); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data.0.id'); + }); + + it('control: `BaseSchema` alone would have admitted both of those', () => { + // The measurement behind "kept declared rather than deleted", made visible + // rather than asserted in prose: the base object takes the very values the + // member above refuses. Delete the member and the two refusals become these + // two acceptances. + expect(BaseSchema.safeParse({ type: 'tree-view', data: 'not-an-array' }).success).toBe(true); + expect(BaseSchema.safeParse({ type: 'tree-view', data: [{ label: 'no id' }] }).success).toBe(true); + }); + + it('control: an UNDECLARED key is still admitted unexamined, exactly as before', () => { + // `BaseSchema` is `.passthrough()`, so the refusals above measure the + // declaration and not the object's strictness. This is also the proof that + // the unknown-key policy did not move with the optionality. + // + // ⚠️ The carrier is `data`-bearing ON PURPOSE, so it is a legal document + // with or without this card and this control can only fail for its own + // reason. Measured: written over a `nodes`-only carrier it reddens under + // the ablation below — not because passthrough moved, but because the + // pre-repair mirror refuses the carrier itself. A control that fails for + // the change it is controlling FOR is not a control. + const r = TreeViewSchema.safeParse({ ...ROOT, data: [], [SENTINEL]: 'not-an-array' }); + expect(r.success).toBe(true); + if (r.success) expect((r.data as Record)[SENTINEL]).toBe('not-an-array'); + }); + + it('`nodes` keeps the enforcement objectui#6150 gave it', () => { + expect(TreeViewSchema.safeParse({ ...ROOT, nodes: 'not-an-array' }).success).toBe(false); + expect(TreeViewSchema.safeParse({ ...ROOT, title: 42 }).success).toBe(false); + }); +}); + +describe('objectui#6939 — the declaration still names a live read', () => { + it('the renderer reads `data` as the third limb', () => { + // A key whose reader is gone must be DROPPED, not declared (objectui#6150's + // own rule). Line numbers drift and stay in prose; the READ is the fact. + const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); + expect(src, `${READER} no longer reads \`schema.data\` as \`${READ_TEXT}\``).toContain(READ_TEXT); + }); + + it('and it reads `nodes` ahead of it — the order the relaxation rests on', () => { + const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); + expect(src.indexOf('schema.nodes')).toBeLessThan(src.indexOf('schema.data ||')); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 588502039b..e4f16b1261 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1218,8 +1218,18 @@ export interface TreeViewSchema extends BaseSchema { * * READ SITE: `packages/components/src/renderers/data-display/tree-view.tsx:105` * — `const rawNodes = boundData || schema.nodes || schema.data || []`. + * + * OPTIONAL since objectui#6939. It was REQUIRED, which refused four catalog + * entries the renderer draws correctly — a third-choice limb cannot be the + * one key a document must carry. + * + * ⚠️ Kept DECLARED rather than deleted, and the distinction is measured: + * {@link BaseSchema} already declares `data?: any` (its zod twin is + * `z.any().optional()`), so removing this member would NOT reject the key — + * it would admit it unvalidated while the renderer still reads it. Declaring + * it optional is the only shape in which `declared` and `enforced` agree. */ - data: TreeNode[]; + data?: TreeNode[]; /** * Tree data — the spelling the renderer reads FIRST. * @@ -1228,11 +1238,12 @@ export interface TreeViewSchema extends BaseSchema { * {@link TreeViewSchema.data} when both are authored (and a `bind`-resolved * value wins over both). * - * ⚠️ Declaring `nodes` does NOT by itself make `{ type: 'tree-view', nodes }` - * a legal document: {@link TreeViewSchema.data} stays REQUIRED on both faces, - * so the validator still demands `data`. Relaxing that is an accept-set - * change and a separate ruling — objectui#6150 declares the read, nothing - * more. + * Declared by objectui#6150, which deliberately stopped at the declaration: + * `{ type: 'tree-view', nodes }` only became a LEGAL document at + * objectui#6939, the accept-set change that relaxed + * {@link TreeViewSchema.data}. The registration's own `inputs` and + * `defaultProps` spell it `nodes`, and the four catalog entries ARE those + * `defaultProps`. */ nodes?: TreeNode[]; /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index d1d427db8a..a0f1499dff 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -313,10 +313,10 @@ export const TreeNodeSchema: z.ZodType = z.lazy(() => */ export const TreeViewSchema = BaseSchema.extend({ type: z.literal('tree-view'), - data: z.array(TreeNodeSchema) - .describe('Tree data, read as the fallback limb of `boundData || schema.nodes || schema.data || []` at renderers/data-display/tree-view.tsx:105'), + data: z.array(TreeNodeSchema).optional() + .describe('Tree data, read THIRD as the fallback limb of `boundData || schema.nodes || schema.data || []` at renderers/data-display/tree-view.tsx:105. OPTIONAL since objectui#6939 — requiring a third-choice limb refused four catalog entries the renderer draws correctly. Kept DECLARED rather than deleted: `BaseSchema.data` is `z.any().optional()`, so removing this member would not reject the key, it would admit it unvalidated while the read stays'), nodes: z.array(TreeNodeSchema).optional() - .describe('Tree data, read FIRST at renderers/data-display/tree-view.tsx:105 — the middle limb of `boundData || schema.nodes || schema.data || []`, so it wins over `data`. ⚠️ `data` stays REQUIRED here: declaring `nodes` does not by itself make a `nodes`-only document legal (objectui#6150)'), + .describe('Tree data, read FIRST at renderers/data-display/tree-view.tsx:105 — the middle limb of `boundData || schema.nodes || schema.data || []`, so it wins over `data`. Declared by objectui#6150; a `nodes`-only document became LEGAL at objectui#6939, which relaxed `data` (the registration\'s own `inputs` and `defaultProps` spell it `nodes`, and the four catalog entries ARE those `defaultProps`)'), title: z.string().optional() .describe('Heading above the tree, read at renderers/data-display/tree-view.tsx:115 (presence gate) and :117 (the h3 body) (objectui#6150)'), defaultExpandedIds: z.array(z.string()).optional().describe('Default expanded node IDs'), From 5d7e47fa7f47900610162190c5ac8d455598a88f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 18:11:04 +0000 Subject: [PATCH 2/2] docs(types): correct three statements the tree-view repair made false Contract review of PR 7533 passed the change on every axis and failed three statements ABOUT it. None of the code under repair moves here. 1. The compile-time pin's comment named the wrong mechanism. Deleting the member does not fall through BaseSchema's `[key: string]: any`; it resolves to the INHERITED declared `BaseSchema.data?: any` (base.ts:183), because a declared member outranks an index signature. Verified independently before accepting the finding: strip the index signature with the homomorphic keyof-remap and `['data']` is still `any`, while an index-signature-only key stops resolving at all -- both controls lit. The pin's verdict is unchanged; only the reason it gives was wrong, and it contradicted the file header, the TS-face doc comment and the mirror's describe(). 2. `content/docs/api/schema-reference.md` still called `data` **Required**, which this branch made false, and no gate covers that row. Corrected, and a `nodes` row added beside it so the corrected sentence does not reference a key the table never lists. The two sibling groups of this card each updated their own doc page; this one had missed it. 3. The changeset called the change a widening "on both faces" without saying that a READER of the TypeScript twin sees a narrowing: `data` is now `TreeNode[] | undefined`, so an unguarded `schema.data` read stops compiling. The PR body already said this; the changeset is the artefact that becomes the CHANGELOG, so it is the one that had to. Level is unchanged and still patch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/6939-tree-view-nodes-mirror.md | 26 ++++++++++++++----- content/docs/api/schema-reference.md | 3 ++- .../tree-view-data-optional-6939.test.ts | 17 ++++++++++-- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.changeset/6939-tree-view-nodes-mirror.md b/.changeset/6939-tree-view-nodes-mirror.md index f0c88a28a0..239c1e5d54 100644 --- a/.changeset/6939-tree-view-nodes-mirror.md +++ b/.changeset/6939-tree-view-nodes-mirror.md @@ -21,12 +21,26 @@ byte-identical under either spelling (28 / 28 / 12 / 34 elements, same tag census, same `textContent` SHA-256). Identical output under the "correction" is objectui#6318's own triage test for *the schema was the wrong side*. -**This is a WIDENING, on both faces.** `data` goes from required to optional on -the mirror and on the TypeScript twin in the same stroke; nothing that validated -before validates less. A document authored on `data` — such as the tree-view -entry in `packages/types/examples/data-display-examples.json` — is untouched, -and both spellings together stay legal. Hence `patch`, matching the two sibling -groups of this card that have already landed. +**For AUTHORS this widens on both faces.** `data` goes from required to optional +on the mirror and on the TypeScript twin in the same stroke; nothing that +validated before validates less, and no document that type-checked as a literal +stops doing so. A document authored on `data` — such as the tree-view entry in +`packages/types/examples/data-display-examples.json` — is untouched, and both +spellings together stay legal. + +**For a READER of the TypeScript twin this is a narrowing, and that is the half +worth stating.** `TreeViewSchema['data']` is now `TreeNode[] | undefined`, so +code that read `schema.data` and relied on its presence needs a guard and will +otherwise stop compiling (measured on a consumer probe: exit 0 before, `TS2322` +plus `TS18048` after). The only in-repo reader already has that guard — +`renderers/data-display/tree-view.tsx:105` reads +`boundData || schema.nodes || schema.data || []` — and it type-checks clean, so +nothing in this repository changes. An out-of-repo consumer that reads the key +unguarded is the population this paragraph exists for. + +Still `patch`: the required-ness was never a guarantee the renderer honoured (it +reads the key third, behind a default), the accept set only grows, and this is +the same shape as the two sibling groups of this card that have already landed. **`data` stays DECLARED rather than being deleted**, and the difference is measured rather than assumed: `BaseSchema` already declares `data` diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 7dd7b7d638..fcb6dd8d77 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -524,7 +524,8 @@ A hierarchical tree component for nested data with expand/collapse and selection | Property | Type | Description | |----------|------|-------------| -| `data` | `TreeNode[]` | **Required.** Nested tree data. Each node has `id`, `label`, optional `icon` and `children`. | +| `nodes` | `TreeNode[]` | Optional. Nested tree data — the spelling the renderer reads FIRST, and the one the component's own `inputs` and `defaultProps` use. Each node has `id`, `label`, optional `icon` and `children`. | +| `data` | `TreeNode[]` | Optional. Nested tree data, read only when `nodes` is absent (the renderer reads `nodes` first — objectui#6939). | | `defaultExpandedIds` | `string[]` | Node IDs expanded on initial render. | | `defaultSelectedIds` | `string[]` | Node IDs selected on initial render. | | `expandedIds` | `string[]` | Controlled expanded state. | diff --git a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts index 5db922cd28..ecba9b125f 100644 --- a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts +++ b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts @@ -65,8 +65,21 @@ type Expect< T extends true > = T; * directions that matter: * * - required again -> `TreeNode[]` is not `TreeNode[] | undefined` -> red - * - member DELETED -> falls through `BaseSchema`'s `[key: string]: any` - * to `any`, and `Equal< any, … >` is false -> red + * - member DELETED -> resolves to the INHERITED `BaseSchema.data?: any` + * (`base.ts:183`) — `any`, and `Equal< any, … >` is + * false -> red + * + * ⚠️ The second limb is NOT the index signature, and the difference matters + * because it is the whole reason this card keeps the member instead of deleting + * it. `BaseSchema` carries BOTH `data?: any` (`base.ts:183`) and + * `[key: string]: any` (`base.ts:409`), and a declared member — inherited or + * not — wins over an index signature. Measured: strip the index signature with + * the homomorphic `keyof`-remap that `zod-mirror-parity.test.ts` uses, and + * `['data']` is STILL `any`, while a key reachable only through the index + * signature stops resolving at all. So deletion does not fall through to an + * open bag; it lands on a declared, untyped inherited member — which is + * exactly what the file header, the TS-face doc comment and the mirror's + * `.describe()` all say, and what this comment used to contradict. */ export type _TreeDataIsOptionalTreeNodes = Expect< Equal< TsTreeViewSchema['data'], TreeNode[] | undefined > >;