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
78 changes: 78 additions & 0 deletions .changeset/6951-tree-view-data-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
'@object-ui/types': minor
'@object-ui/components': minor
---

**Breaking for authored metadata:** `TreeViewSchema.data` is RETIRED (objectui#6951,
maintainer ruling B1 of 2026-09-04; ADR-0049 enforce-or-remove). A `tree-view`
node that authors `data` no longer validates: the parse fails loudly on the
`data` path with the explanation in the message, the TS member is a `?: never`
tombstone so the same document is refused at compile time, and the renderer no
longer reads the key. Write `nodes` — or bind the tree with `bind`, which is
unchanged and still read first.

**What was measured, on this branch's base.** `TreeViewSchema` declared two
spellings for its one inline-nodes slot — `nodes` (read second) and `data` (read
third: `boundData || schema.nodes || schema.data || []` at
`renderers/data-display/tree-view.tsx:105`), both declared by objectui#6150.
`data` had been REQUIRED until objectui#6939 / PR #7533 made it optional, so
this retirement starts from a declared-and-optional member on both faces. The
in-repo corpus at the retirement: seven `tree-view` nodes under
`examples/schema-catalog` and `packages/types/examples` plus one `content/docs`
fence — six on `nodes`, two on `data` (`packages/types/examples/data-display-examples.json`
and `content/docs/api/schema-reference.md`), both rewritten; no package source
authored either spelling.

**Who is affected — a `data` authored on a `tree-view` node:**

```json
{ "type": "tree-view",
"data": [{ "id": "root", "label": "Project" }] } // ← was tolerated (read third)
```

now fails validation with:

> RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write
> `nodes` (or bind the tree with `bind`). It was the second spelling of the one
> inline-nodes slot, read only as the last limb of
> `boundData || schema.nodes || schema.data || []`, and was retired under
> ADR-0049 enforce-or-remove with no deprecation window (maintainer ruling B1,
> 2026-09-04). The renderer reads `bind` then `nodes` now, so an authored `data`
> would render an empty tree. Rename the key; the array is unchanged.

**Two published faces, one retirement — and why a tombstone, not a deletion.**
The TypeScript interface `TreeViewSchema` (`@object-ui/types`, `data-display.ts`)
declares `data?: never`; the Zod mirror `TreeViewSchema` (`@object-ui/types/zod`,
`data-display.zod.ts`) declares `data` as a `retirementTombstone()`. `BaseSchema`
already declares `data?: any` (`z.any().optional()` on the mirror), so DELETING
the member would not have refused the key — it would have ADMITTED it,
unvalidated, through the base member, and the renderer would have drawn an empty
tree. The tombstone on the extended schema shadows the base member on both
faces; the pin measures the base accepting the very document the extended
schema refuses.

**What the ruling kept, deliberately.** `nodes` stays OPTIONAL and no "at least
one of" presence rule was added: `{ "type": "tree-view", "bind": "treeNodes" }`
is a legal, rendering document (`bind` is the first source the renderer reads),
and a bare `{ "type": "tree-view" }` stays legal as PR #7533 left it.
`TreeNode.data` — the per-node payload on each tree node — is a different
member on a different schema and is untouched.

**`@object-ui/components`** — the `tree-view` renderer's read is
`boundData || schema.nodes || []`; nothing else in the package moves.

**Who is NOT affected.** A document that already wrote `nodes` (the four
`components-data-display-tree-view/*` catalog entries and the nested tree in
`components-complex-resizable/editor-interface.json`) is untouched; `title`,
`bind`, the selection / expansion keys and `className` are unchanged. The
catalog is now pinned tree-wide against the retired spelling.

**Migration:** rename `data` to `nodes` on every `tree-view` node; the array is
unchanged. If a document authored both, `nodes` was already the value that
rendered — delete `data`.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated spelling. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ A hierarchical tree component for nested data with expand/collapse and selection
"multiSelect": false,
"showLines": true,
"defaultExpandedIds": ["root", "src"],
"data": [
"nodes": [
{
"id": "root",
"label": "project",
Expand Down
19 changes: 16 additions & 3 deletions content/docs/components/data-display/tree-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ interface TreeNode {
interface TreeViewSchema {
type: 'tree-view';

// Data
data?: TreeNode[]; // Tree data (alias for nodes)
nodes?: TreeNode[]; // Tree nodes
// Data — inline nodes, or bind the tree with `bind` (read first)
nodes?: TreeNode[]; // Tree nodes (the one inline spelling)
title?: string; // Tree title

// Selection
Expand All @@ -55,6 +54,20 @@ interface TreeViewSchema {
}
```

> **Retired: `data`** (objectui#6951, ADR-0049 enforce-or-remove). `data` was a
> second spelling of the tree's one inline-nodes slot — the renderer read it only
> as the last limb of `bind || nodes || data` — and it is no longer part of
> `TreeViewSchema` on either face. A `tree-view` node that authors `data` now
> fails validation with:
>
> > RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write
> > `nodes` (or bind the tree with `bind`). …
>
> Rename the key to `nodes`; the array is unchanged. `bind` is unchanged and is
> still read first, so a `bind`-only tree-view stays a legal document, and
> `nodes` stays optional (no presence rule was added). `TreeNode.data` — the
> per-node payload — is a different member and is untouched.

## Examples

### Organization Chart
Expand Down
49 changes: 30 additions & 19 deletions examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,19 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer
});

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));
it.each(IDS)('%s: spelling it `data` now draws an EMPTY tree — the retired limb is not read (objectui#6951)', (id) => {
// Flipped. At objectui#6939 the spelling swap moved nothing (the renderer
// read `data` third), which is how the fixtures were shown to be the right
// side. objectui#6951 then RETIRED `data` on both faces and dropped the
// renderer's read of it, so the same swap now blanks the tree body: the
// title still draws, every authored root label is gone. That is the
// enforce-or-remove half measured through the DOM.
const schema = asAuthored(id) as { title: string; nodes: { label: string }[] };
const authored = measure(schema);
const corrected = measure(asDataSpelling(id));
expect(corrected.elements).toBe(authored.elements);
expect(corrected.text).toBe(authored.text);
expect(corrected.sha256).toBe(authored.sha256);
expect(corrected.elements).toBeLessThan(authored.elements);
expect(corrected.text).toContain(schema.title);
for (const node of schema.nodes) expect(corrected.text).not.toContain(node.label);
});

it.each(IDS)('%s stays on the spelling its renderer reads FIRST', (id) => {
Expand All @@ -171,13 +174,21 @@ describe('objectui#6939 — the fixtures were the side that was right', () => {
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.
it('only `nodes` validates now — `data` is refused by name (objectui#6951)', () => {
// Flipped from "both spellings validate". ⛔ Do NOT "repair" a future red
// here by migrating the fixtures to `data`: that spelling is retired. The
// refusal names the key and the spelling to write instead.
for (const id of IDS) {
expect(reasons(asAuthored(id))).toEqual([]);
expect(reasons(asDataSpelling(id))).toEqual([]);
// The union door refuses (its top-level issue is the `invalid_union`
// envelope); the arm-level issue on the `data` path carries the guidance.
expect(reasons(asDataSpelling(id)).length).toBeGreaterThan(0);
const direct = TreeViewSchema.safeParse(asDataSpelling(id));
expect(direct.success).toBe(false);
if (!direct.success) {
const issue = direct.error.issues.find((i) => i.path.join('.') === 'data');
expect(issue?.message).toContain('write `nodes`');
}
}
});

Expand All @@ -189,12 +200,12 @@ describe('objectui#6939 — the fixtures were the side that was right', () => {
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.)
// unrelated reason. (`data` stays in the shape as a TOMBSTONE since
// objectui#6951 — declared and refused by name, never a passthrough hole.)
// objectui#6951: the carrier is `nodes`-only now — `data` is retired, so a
// both-spellings carrier would redden for the retirement, not for shape.
expect(TreeViewSchema.safeParse({
type: 'tree-view', title: 'File Explorer', data: [], nodes: [{ id: '1', label: 'Documents' }],
type: 'tree-view', title: 'File Explorer', nodes: [{ id: '1', label: 'Documents' }],
}).success).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ ComponentRegistry.register('tree-view',

// Support data binding
const boundData = useDataScope(schema.bind);
const rawNodes = boundData || schema.nodes || schema.data || [];
const rawNodes = boundData || schema.nodes || [];
const nodes = Array.isArray(rawNodes) ? rawNodes : [];

return (
Expand Down
2 changes: 1 addition & 1 deletion packages/types/examples/data-display-examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
"tree-view": {
"type": "tree-view",
"id": "file-explorer",
"data": [
"nodes": [
{
"id": "root",
"label": "Project",
Expand Down
70 changes: 50 additions & 20 deletions packages/types/src/__tests__/tree-view-data-optional-6939.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@
* stopped there and wrote so: relaxing `data` is an accept-set change and a
* separate ruling. This is that change; `nodes` and `title` are untouched here.
*
* ## What objectui#6951 (maintainer ruling B1, 2026-09-04) did to this pin
*
* `data` was RETIRED outright — `?: never` on the TS face, `retirementTombstone()`
* on the mirror, and the renderer's read is now `boundData || schema.nodes || []`.
* The relaxation this file records is still history worth keeping: `nodes` stays
* optional, a document with no inline source stays legal (no refinement, as ruled),
* and the "deleted member falls through to `BaseSchema.data`" measurement is
* exactly why the retirement is a tombstone and not a deletion. The pins below
* that asserted `data` ACCEPTED are flipped to refusal in place; the refusal
* envelope itself is pinned in `tree-view-data-retired-6951.test.ts`.
*
* ## Why `data` stays DECLARED instead of being deleted
*
* Deleting the member is the intuitive reading of "the renderer prefers
Expand All @@ -52,7 +63,7 @@ import type { TreeNode, TreeViewSchema as TsTreeViewSchema } from '../data-displ
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(HERE, '..', '..', '..', '..');
const READER = 'packages/components/src/renderers/data-display/tree-view.tsx';
const READ_TEXT = 'boundData || schema.nodes || schema.data || []';
const READ_TEXT = 'boundData || schema.nodes || []';

/* ── Type-level pins (invariant equality, house form) ─────────────────────── */

Expand Down Expand Up @@ -81,8 +92,12 @@ type Expect< T extends true > = T;
* exactly what the file header, the TS-face doc comment and the mirror's
* `.describe()` all say, and what this comment used to contradict.
*/
export type _TreeDataIsOptionalTreeNodes =
Expect< Equal< TsTreeViewSchema['data'], TreeNode[] | undefined > >;
export type _TreeDataIsRetired =
Expect< Equal< TsTreeViewSchema['data'], undefined > >;
// ^ objectui#6951: `data?: never` reads as `undefined`. The second limb above
// still bites — a DELETED member resolves to the inherited `any` and this
// equality goes red — and so does the first: `TreeNode[] | undefined` (the
// pre-retirement declaration) is not `undefined`.

/** `nodes` and `title` are objectui#6150's and are unchanged by this card. */
export type _TreeNodesStillTreeNodes =
Expand Down Expand Up @@ -123,13 +138,16 @@ describe('objectui#6939 — a `nodes`-only tree-view is a legal document', () =>
}
});

it('the accept set only WIDENED — every `data` document that parsed still parses', () => {
// The patch reasoning. Nothing that validated before this change may stop
// validating: `packages/types/examples/data-display-examples.json` authors
// its tree-view on `data`, and the renderer still reads that limb third.
expect(TreeViewSchema.safeParse({ ...ROOT, data: NODES }).success).toBe(true);
expect(TreeViewSchema.safeParse({ ...ROOT, data: NODES, nodes: NODES }).success).toBe(true);
expect(TreeViewSchema.safeParse({ ...ROOT, data: [] }).success).toBe(true);
it('every `data` document is now REFUSED by name — objectui#6951 retired the spelling', () => {
// Flipped from "the accept set only WIDENED": PR #7533 relaxed `data` and
// objectui#6951 then retired it. `packages/types/examples/data-display-examples.json`
// now authors its tree-view on `nodes`. The refusal lands on the `data`
// path with the guidance; the envelope is pinned in the retirement file.
for (const doc of [{ ...ROOT, data: NODES }, { ...ROOT, data: NODES, nodes: NODES }, { ...ROOT, data: [] }]) {
const r = TreeViewSchema.safeParse(doc);
expect(r.success).toBe(false);
if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data');
}
});

it('a tree-view with NO data source at all is legal, and that admits nothing new', () => {
Expand All @@ -153,10 +171,17 @@ describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED',
if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data');
});

it('element-level enforcement survives the relaxation', () => {
it('a well-formed `data` array is refused at the KEY, not at an element — the tombstone, not element validation', () => {
// Pre-retirement this pinned `data.0.id` (element-level enforcement). The
// tombstone refuses the key itself, so the path is `data` and the message
// names the spelling to write instead.
const r = TreeViewSchema.safeParse({ ...ROOT, data: [{ label: 'no id' }] });
expect(r.success).toBe(false);
if (!r.success) expect(r.error.issues.map((i) => i.path.join('.'))).toContain('data.0.id');
if (!r.success) {
const issue = r.error.issues.find((i) => i.path.join('.') === 'data');
expect(issue).toBeTruthy();
expect(issue?.message).toContain('write `nodes`');
}
});

it('control: `BaseSchema` alone would have admitted both of those', () => {
Expand All @@ -179,7 +204,11 @@ describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED',
// the ablation below — not because passthrough moved, but because the
// pre-repair mirror refuses the carrier itself. A control that fails for
// the change it is controlling FOR is not a control.
const r = TreeViewSchema.safeParse({ ...ROOT, data: [], [SENTINEL]: 'not-an-array' });
// objectui#6951: the carrier moved from `data: []` to `nodes: []` — the
// same reasoning, the other way round: `data` is now the retired spelling,
// so a `data`-bearing carrier would redden for the retirement, not for
// passthrough. `nodes: []` is legal before and after.
const r = TreeViewSchema.safeParse({ ...ROOT, nodes: [], [SENTINEL]: 'not-an-array' });
expect(r.success).toBe(true);
if (r.success) expect((r.data as Record<string, unknown>)[SENTINEL]).toBe('not-an-array');
});
Expand All @@ -190,16 +219,17 @@ describe('objectui#6939 — `data` is still DECLARED, so it is still VALIDATED',
});
});

describe('objectui#6939 — the declaration still names a live read', () => {
it('the renderer reads `data` as the third limb', () => {
// A key whose reader is gone must be DROPPED, not declared (objectui#6150's
// own rule). Line numbers drift and stay in prose; the READ is the fact.
describe('objectui#6951 — the renderer no longer reads the retired limb', () => {
it('the read is `boundData || schema.nodes || []` — `bind` first, `nodes` second, nothing third', () => {
// Enforce-or-remove: a retired key must stop being READ as well as declared.
// Line numbers drift and stay in prose; the READ is the fact.
const src = readFileSync(join(REPO_ROOT, READER), 'utf8');
expect(src, `${READER} no longer reads \`schema.data\` as \`${READ_TEXT}\``).toContain(READ_TEXT);
expect(src, `${READER} does not read \`${READ_TEXT}\``).toContain(READ_TEXT);
expect(src.match(/schema\.data\b/g)).toBeNull();
});

it('and it reads `nodes` ahead of it — the order the relaxation rests on', () => {
it('and `bind` is still read ahead of `nodes` — the order the no-refinement ruling rests on', () => {
const src = readFileSync(join(REPO_ROOT, READER), 'utf8');
expect(src.indexOf('schema.nodes')).toBeLessThan(src.indexOf('schema.data ||'));
expect(src.indexOf('useDataScope(schema.bind)')).toBeLessThan(src.indexOf('schema.nodes'));
});
});
Loading
Loading