Skip to content

Commit db4c848

Browse files
os-justinclaude
andcommitted
feat(types)!: retire TreeViewSchema.data as an ADR-0049 tombstone; nodes is the only inline spelling (objectui#6951 B1)
`TreeViewSchema` declared two spellings for its one inline-nodes slot: `nodes` (read second, after a `bind`-resolved value) and `data` (read third), both declared by objectui#6150; `data` was required until objectui#6939 / PR #7533 made it optional. Maintainer ruling B1 (2026-09-04): retire `data`; `nodes` stays optional; no presence refinement (a `bind`-only tree-view is a legal, rendering document). `BaseSchema` declares `data?: any` / `z.any().optional()`, so deleting the member would ADMIT the key unvalidated; the retirement is a tombstone on the extended schema that shadows the base member, and the base-vs-extended contrast is pinned on both faces. - `data?: never` on the interface, `retirementTombstone()` named refusal on the Zod mirror (message names `nodes` and `bind`); `nodes` docblocks and describe rewritten without the three-limb narrative - tree-view.tsx reads `boundData || schema.nodes || []` - packages/types/examples/data-display-examples.json and the content/docs/api/schema-reference.md fence spell `nodes`; docs row replaced by a retirement note - PR #7533's pins that asserted `data` accepted flipped to refusal in place; the catalog mirror test's data-spelling probes flipped; #6150 control document moves to `nodes: []` - new pin tree-view-data-retired-6951: both faces refuse, the union entry carries the guidance, BaseSchema alone accepts the same document (both faces), `nodes`-only / `bind`-only / bare document parse, TreeNode.data control, tree-scoped catalog absence, renderer read set off disk, tsc legs - changeset (types/components minor) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s
1 parent 3e377c9 commit db4c848

11 files changed

Lines changed: 572 additions & 75 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/components': minor
4+
---
5+
6+
**Breaking for authored metadata:** `TreeViewSchema.data` is RETIRED (objectui#6951,
7+
maintainer ruling B1 of 2026-09-04; ADR-0049 enforce-or-remove). A `tree-view`
8+
node that authors `data` no longer validates: the parse fails loudly on the
9+
`data` path with the explanation in the message, the TS member is a `?: never`
10+
tombstone so the same document is refused at compile time, and the renderer no
11+
longer reads the key. Write `nodes` — or bind the tree with `bind`, which is
12+
unchanged and still read first.
13+
14+
**What was measured, on this branch's base.** `TreeViewSchema` declared two
15+
spellings for its one inline-nodes slot — `nodes` (read second) and `data` (read
16+
third: `boundData || schema.nodes || schema.data || []` at
17+
`renderers/data-display/tree-view.tsx:105`), both declared by objectui#6150.
18+
`data` had been REQUIRED until objectui#6939 / PR #7533 made it optional, so
19+
this retirement starts from a declared-and-optional member on both faces. The
20+
in-repo corpus at the retirement: seven `tree-view` nodes under
21+
`examples/schema-catalog` and `packages/types/examples` plus one `content/docs`
22+
fence — six on `nodes`, two on `data` (`packages/types/examples/data-display-examples.json`
23+
and `content/docs/api/schema-reference.md`), both rewritten; no package source
24+
authored either spelling.
25+
26+
**Who is affected — a `data` authored on a `tree-view` node:**
27+
28+
```json
29+
{ "type": "tree-view",
30+
"data": [{ "id": "root", "label": "Project" }] } // ← was tolerated (read third)
31+
```
32+
33+
now fails validation with:
34+
35+
> RETIRED (objectui#6951) — `data` is no longer part of TreeViewSchema; write
36+
> `nodes` (or bind the tree with `bind`). It was the second spelling of the one
37+
> inline-nodes slot, read only as the last limb of
38+
> `boundData || schema.nodes || schema.data || []`, and was retired under
39+
> ADR-0049 enforce-or-remove with no deprecation window (maintainer ruling B1,
40+
> 2026-09-04). The renderer reads `bind` then `nodes` now, so an authored `data`
41+
> would render an empty tree. Rename the key; the array is unchanged.
42+
43+
**Two published faces, one retirement — and why a tombstone, not a deletion.**
44+
The TypeScript interface `TreeViewSchema` (`@object-ui/types`, `data-display.ts`)
45+
declares `data?: never`; the Zod mirror `TreeViewSchema` (`@object-ui/types/zod`,
46+
`data-display.zod.ts`) declares `data` as a `retirementTombstone()`. `BaseSchema`
47+
already declares `data?: any` (`z.any().optional()` on the mirror), so DELETING
48+
the member would not have refused the key — it would have ADMITTED it,
49+
unvalidated, through the base member, and the renderer would have drawn an empty
50+
tree. The tombstone on the extended schema shadows the base member on both
51+
faces; the pin measures the base accepting the very document the extended
52+
schema refuses.
53+
54+
**What the ruling kept, deliberately.** `nodes` stays OPTIONAL and no "at least
55+
one of" presence rule was added: `{ "type": "tree-view", "bind": "treeNodes" }`
56+
is a legal, rendering document (`bind` is the first source the renderer reads),
57+
and a bare `{ "type": "tree-view" }` stays legal as PR #7533 left it.
58+
`TreeNode.data` — the per-node payload on each tree node — is a different
59+
member on a different schema and is untouched.
60+
61+
**`@object-ui/components`** — the `tree-view` renderer's read is
62+
`boundData || schema.nodes || []`; nothing else in the package moves.
63+
64+
**Who is NOT affected.** A document that already wrote `nodes` (the four
65+
`components-data-display-tree-view/*` catalog entries and the nested tree in
66+
`components-complex-resizable/editor-interface.json`) is untouched; `title`,
67+
`bind`, the selection / expansion keys and `className` are unchanged. The
68+
catalog is now pinned tree-wide against the retired spelling.
69+
70+
**Migration:** rename `data` to `nodes` on every `tree-view` node; the array is
71+
unchanged. If a document authored both, `nodes` was already the value that
72+
rendered — delete `data`.
73+
74+
Graded `minor`, not `patch`: this narrows the accepted input set, which is
75+
breaking for any author who wrote the tolerated spelling. It is not `major` per
76+
this repo's fixed-group convention (objectui's own breaking changes ship as
77+
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
78+
mechanically enforced by `scripts/check-changeset-no-major.mjs`).

‎content/docs/api/schema-reference.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ A hierarchical tree component for nested data with expand/collapse and selection
514514
"multiSelect": false,
515515
"showLines": true,
516516
"defaultExpandedIds": ["root", "src"],
517-
"data": [
517+
"nodes": [
518518
{
519519
"id": "root",
520520
"label": "project",

‎content/docs/components/data-display/tree-view.mdx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ interface TreeNode {
2727
interface TreeViewSchema {
2828
type: 'tree-view';
2929
30-
// Data
31-
data?: TreeNode[]; // Tree data (alias for nodes)
32-
nodes?: TreeNode[]; // Tree nodes
30+
// Data — inline nodes, or bind the tree with `bind` (read first)
31+
nodes?: TreeNode[]; // Tree nodes (the one inline spelling)
3332
title?: string; // Tree title
3433
3534
// Selection
@@ -55,6 +54,20 @@ interface TreeViewSchema {
5554
}
5655
```
5756

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

6073
### Organization Chart

‎examples/schema-catalog/test/tree-view-nodes-mirror-6939.test.tsx‎

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,19 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer
153153
});
154154

155155
describe('objectui#6939 — the fixtures were the side that was right', () => {
156-
it.each(IDS)('%s: "correcting" it to `data` changes no pixel', (id) => {
157-
// The card's own discriminator, re-measured here rather than quoted: the
158-
// spelling swap moves nothing, so the schema was the wrong side. Contrast
159-
// the same probe on the sibling groups, where the "correction" emptied the
160-
// board or blanked the tile.
161-
const authored = measure(asAuthored(id));
156+
it.each(IDS)('%s: spelling it `data` now draws an EMPTY tree — the retired limb is not read (objectui#6951)', (id) => {
157+
// Flipped. At objectui#6939 the spelling swap moved nothing (the renderer
158+
// read `data` third), which is how the fixtures were shown to be the right
159+
// side. objectui#6951 then RETIRED `data` on both faces and dropped the
160+
// renderer's read of it, so the same swap now blanks the tree body: the
161+
// title still draws, every authored root label is gone. That is the
162+
// enforce-or-remove half measured through the DOM.
163+
const schema = asAuthored(id) as { title: string; nodes: { label: string }[] };
164+
const authored = measure(schema);
162165
const corrected = measure(asDataSpelling(id));
163-
expect(corrected.elements).toBe(authored.elements);
164-
expect(corrected.text).toBe(authored.text);
165-
expect(corrected.sha256).toBe(authored.sha256);
166+
expect(corrected.elements).toBeLessThan(authored.elements);
167+
expect(corrected.text).toContain(schema.title);
168+
for (const node of schema.nodes) expect(corrected.text).not.toContain(node.label);
166169
});
167170

168171
it.each(IDS)('%s stays on the spelling its renderer reads FIRST', (id) => {
@@ -171,13 +174,15 @@ describe('objectui#6939 — the fixtures were the side that was right', () => {
171174
expect('data' in schema).toBe(false);
172175
});
173176

174-
it('both spellings validate — the accept set widened, it did not move', () => {
175-
// ⛔ Do NOT "repair" a future red here by migrating the fixtures to `data`.
176-
// The renderer reads `nodes` first and the registration's `defaultProps`
177-
// spell it `nodes`; the fixtures ARE those defaults.
177+
it('only `nodes` validates now — `data` is refused by name (objectui#6951)', () => {
178+
// Flipped from "both spellings validate". ⛔ Do NOT "repair" a future red
179+
// here by migrating the fixtures to `data`: that spelling is retired. The
180+
// refusal names the key and the spelling to write instead.
178181
for (const id of IDS) {
179182
expect(reasons(asAuthored(id))).toEqual([]);
180-
expect(reasons(asDataSpelling(id))).toEqual([]);
183+
const why = reasons(asDataSpelling(id));
184+
expect(why.length).toBeGreaterThan(0);
185+
expect(why.some((r) => r.startsWith('data:') && r.includes('write `nodes`'))).toBe(true);
181186
}
182187
});
183188

@@ -189,12 +194,12 @@ describe('objectui#6939 — the fixtures were the side that was right', () => {
189194
expect(TreeViewSchema.safeParse({ type: 'tree-view', nodes: 'not-an-array' }).success).toBe(false);
190195
expect(TreeViewSchema.safeParse({ type: 'tree-view', data: 'not-an-array' }).success).toBe(false);
191196
// …and a good shape still passes, so the two above are not failing for some
192-
// unrelated reason. ⚠️ The carrier carries BOTH spellings on purpose: it is
193-
// legal with or without this card, so this control cannot redden for the
194-
// relaxation it is controlling for. (That the authored, `nodes`-only
195-
// fixtures parse is the first describe block's claim, not this one's.)
197+
// unrelated reason. (`data` stays in the shape as a TOMBSTONE since
198+
// objectui#6951 — declared and refused by name, never a passthrough hole.)
199+
// objectui#6951: the carrier is `nodes`-only now — `data` is retired, so a
200+
// both-spellings carrier would redden for the retirement, not for shape.
196201
expect(TreeViewSchema.safeParse({
197-
type: 'tree-view', title: 'File Explorer', data: [], nodes: [{ id: '1', label: 'Documents' }],
202+
type: 'tree-view', title: 'File Explorer', nodes: [{ id: '1', label: 'Documents' }],
198203
}).success).toBe(true);
199204
});
200205
});

‎packages/components/src/renderers/data-display/tree-view.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ ComponentRegistry.register('tree-view',
102102

103103
// Support data binding
104104
const boundData = useDataScope(schema.bind);
105-
const rawNodes = boundData || schema.nodes || schema.data || [];
105+
const rawNodes = boundData || schema.nodes || [];
106106
const nodes = Array.isArray(rawNodes) ? rawNodes : [];
107107

108108
return (

‎packages/types/examples/data-display-examples.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@
189189
"tree-view": {
190190
"type": "tree-view",
191191
"id": "file-explorer",
192-
"data": [
192+
"nodes": [
193193
{
194194
"id": "root",
195195
"label": "Project",

‎packages/types/src/__tests__/tree-view-data-optional-6939.test.ts‎

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@
2929
* stopped there and wrote so: relaxing `data` is an accept-set change and a
3030
* separate ruling. This is that change; `nodes` and `title` are untouched here.
3131
*
32+
* ## What objectui#6951 (maintainer ruling B1, 2026-09-04) did to this pin
33+
*
34+
* `data` was RETIRED outright — `?: never` on the TS face, `retirementTombstone()`
35+
* on the mirror, and the renderer's read is now `boundData || schema.nodes || []`.
36+
* The relaxation this file records is still history worth keeping: `nodes` stays
37+
* optional, a document with no inline source stays legal (no refinement, as ruled),
38+
* and the "deleted member falls through to `BaseSchema.data`" measurement is
39+
* exactly why the retirement is a tombstone and not a deletion. The pins below
40+
* that asserted `data` ACCEPTED are flipped to refusal in place; the refusal
41+
* envelope itself is pinned in `tree-view-data-retired-6951.test.ts`.
42+
*
3243
* ## Why `data` stays DECLARED instead of being deleted
3344
*
3445
* Deleting the member is the intuitive reading of "the renderer prefers
@@ -52,7 +63,7 @@ import type { TreeNode, TreeViewSchema as TsTreeViewSchema } from '../data-displ
5263
const HERE = dirname(fileURLToPath(import.meta.url));
5364
const REPO_ROOT = join(HERE, '..', '..', '..', '..');
5465
const READER = 'packages/components/src/renderers/data-display/tree-view.tsx';
55-
const READ_TEXT = 'boundData || schema.nodes || schema.data || []';
66+
const READ_TEXT = 'boundData || schema.nodes || []';
5667

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

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

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

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

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

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

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

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

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

0 commit comments

Comments
 (0)