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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
61 changes: 61 additions & 0 deletions .changeset/6939-tree-view-nodes-mirror.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@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*.

**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`
(`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.
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
200 changes: 200 additions & 0 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, number>;
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(
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />,
);
const nodes = Array.from(container.querySelectorAll('*'));
const text = container.textContent ?? '';
const out = {
elements: nodes.length,
tags: nodes.reduce<Record<string, number>>((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<string, unknown>;
}
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<string, unknown> }).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);
});
});
Loading
Loading