Skip to content

Commit 2f6b2bf

Browse files
os-warrenclaude
andauthored
fix(types,plugin-tree): derive TreeViewConfig from the spec and drop titleField (#9052)
`@object-ui/types` published `TreeViewConfig` as a hand-written interface — a copy of the protocol's `ListView.tree` block under a second name — and the copy declared a fifth key, `titleField`, that `@objectstack/spec@17.4.0` REFUSES on that block by name (`TreeConfigSchema` is a `strictObject` since spec #15469 closed the `.passthrough()` window 17.3.0 left open). This repo's published face therefore accepted what the contract rejects: an author who followed `@object-ui/types` was refused at publish. Both divergences are addressed: - the KEY — `titleField` is removed from the type, and `getTreeConfig`'s `?? schema.titleField` rung goes with it. That rung read the flattened NODE, never the block, and `titleField` is declared on neither face; it was also unreachable from both in-repo producers of an `object-tree` node, each of which floors `labelField` before the node is built. - the COPY — `TreeViewConfig` is now `NonNullable<SpecListView['tree']>`, a derivation rather than a rename of the spec's `TreeConfig`. The NAME is kept (a consumer census found nine referencing files and no `TreeConfig` free in the barrel); what is retired is the hand copy behind it, which is the half that could drift. The `Pick` interim objectui#8841 offered was conditional on the 17.4.0 bump not having landed. It had: `chore(deps): take the 17.4.0 @objectstack/* line` moved the lockfile to 17.4.0 four hours after this card was filed, so the plain alias is available and no dependency is bumped here. The three `labelField || titleField` dual-reads (plugin-view, plugin-list, app-shell) are kept as undeclared tolerant fallbacks so stored view records keep resolving and objectui#6557's pin stays green; retiring them is a follow-up. The console's canonical rung stays annotated `TreeViewConfig`; its legacy rung is deliberately left untyped rather than re-declared. Both census pins are re-pinned as PARITY WITH THE PROTOCOL rather than as a literal key list — a literal list is what let this drift through, since it was maintained alongside the type it was supposed to audit. Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c3a4273 commit 2f6b2bf

9 files changed

Lines changed: 459 additions & 203 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/plugin-tree': minor
4+
'@object-ui/plugin-view': patch
5+
'@object-ui/app-shell': patch
6+
---
7+
8+
Derive `TreeViewConfig` from `@objectstack/spec` and drop `titleField`, the key the
9+
protocol refuses on `ListView.tree` (objectui#8841).
10+
11+
**What was wrong.** `@object-ui/types` published `TreeViewConfig` as a hand-written
12+
interface — a copy of the protocol's `ListView.tree` block under a second name — and the
13+
copy declared a fifth key, `titleField`. `@objectstack/spec@17.4.0` refuses that key
14+
there by name: `TreeConfigSchema` is a `strictObject` since spec #15469 closed the
15+
`.passthrough()` window 17.3.0 left open. So this package's published face accepted what
16+
the contract rejects, and an author who followed `@object-ui/types` was refused at
17+
publish with `Unrecognized key(s) on this tree configuration: 'titleField'`. The copy was
18+
invisible to `scripts/check-spec-symbol-derivation.mjs`, which matches spec symbols BY
19+
NAME — a hand copy renamed away from the spec's symbol has nothing for its rule 1 to
20+
match (objectui#4592's recorded blind spot).
21+
22+
**The grades, and why.**
23+
24+
- `@object-ui/types` — **minor**. `TreeViewConfig` is now
25+
`NonNullable<ListView['tree']>` from `@objectstack/spec/ui`, and `titleField` is
26+
removed from a **published** exported type. That is breaking for a producer that
27+
annotates a `tree` block carrying the key; per this repo's version-alignment policy
28+
(AGENTS.md — objectui's major tracks `@objectstack`'s) objectui's own breaking changes
29+
ship as `minor` with the breaking semantics stated here. The precedent is the same
30+
shape: `Remove the retired striped / bordered / virtualScroll list-view surface`
31+
(`@object-ui/types` 17.6.0, minor) propagated a spec-side retirement into this package
32+
the same way.
33+
- `@object-ui/plugin-tree` — **minor**. `getTreeConfig`'s `labelField` chain loses its
34+
third rung, `?? schema.titleField`. That rung read the flattened **node**, never the
35+
block, and `titleField` is declared on neither face — not on `ObjectTreeSchema` (the TS
36+
interface or its zod mirror) and not on the protocol's `ListView.tree`. It is a runtime
37+
behaviour change, graded like one. The README's claim that this block is "the single
38+
declaration of that shape" is corrected in the same stroke: the protocol owns it, and
39+
this package publishes it derived.
40+
- `@object-ui/plugin-view` — **patch**. `ObjectViewProps.views[n].tree` still resolves to
41+
`TreeViewConfig`; what it admits narrows with the type. Type-only, no runtime change.
42+
- `@object-ui/app-shell` — **patch**. The console's `tree` composition keeps both rungs;
43+
only the second one's annotation changes (see below). No runtime change.
44+
45+
**The tolerant reads are kept, and deliberately left undeclared.** Three
46+
`labelField || titleField` dual-reads survive — `plugin-view`'s and `plugin-list`'s
47+
`'tree'` branches and the console's own composition in `app-shell` — so a view record
48+
that already stores `tree.titleField` keeps resolving exactly as before, and
49+
objectui#6557's pin on that rung stays green. They read through `any` now; the console's
50+
canonical rung stays annotated `TreeViewConfig` while its legacy rung is not, because
51+
casting it to a type that no longer carries the key cannot compile and re-declaring the
52+
key locally would fossilise a renderer-side alias into a second contract — the AGENTS.md
53+
#0.1 defect this change undoes. Retiring those three reads is a follow-up.
54+
55+
**Migration.** A host that writes `tree.titleField` should write `tree.labelField`, which
56+
is the protocol's spelling and already wins wherever both are present. Nothing that
57+
renders today stops rendering; what changes is that the key is now reported at compile
58+
time by the same face that will refuse it at publish, instead of only at publish.

‎packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,18 +2627,30 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
26272627
// auto-detects when omitted.
26282628
//
26292629
// Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253):
2630-
// `viewDef` is `Record<string, any>`, so both rungs below were
2631-
// `any` property accesses and a misspelling was invisible. This
2632-
// is the half of objectui#7559 a declaration CAN close, on the
2633-
// one block that now has a declaration to close it with — it
2634-
// does NOT make a missing rung visible, which is what the census
2635-
// pin (`ObjectView.relayRungCensus-7559.test.ts`) is for.
2630+
// `viewDef` is `Record<string, any>`, so the canonical rung
2631+
// below was an `any` property access and a misspelling was
2632+
// invisible. This is the half of objectui#7559 a declaration CAN
2633+
// close, on the one block that now has a declaration to close it
2634+
// with — it does NOT make a missing rung visible, which is what
2635+
// the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is
2636+
// for.
26362637
//
26372638
// ⚠️ The cast is repeated per rung rather than hoisted into a
26382639
// local: objectui#6557's convergence pin reads these seam lines
26392640
// out of this file and requires each to name `viewDef` itself.
2641+
//
2642+
// ⛔ The `titleField` rung is deliberately NOT cast (objectui#8841).
2643+
// `TreeViewConfig` is now the spec's `ListView.tree` block, and
2644+
// `@objectstack/spec@17.4.0` refuses `titleField` there by name,
2645+
// so casting to it would not compile and re-declaring the key
2646+
// locally would fossilise a renderer-side alias into a second
2647+
// contract — AGENTS.md #0.1, and the defect objectui#8841 exists
2648+
// to undo. The rung stays as an UNDECLARED tolerant fallback,
2649+
// read through `any`, kept so already-stored view records keep
2650+
// resolving and so objectui#6557's pin on it stays honest. Its
2651+
// retirement is a follow-up, ⛔ not a rider here.
26402652
...((viewDef.tree as TreeViewConfig | undefined) || {}),
2641-
labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || (viewDef.tree as TreeViewConfig | undefined)?.titleField || 'name',
2653+
labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || viewDef.tree?.titleField || 'name',
26422654
},
26432655
// The chart block the view DECLARED, forwarded WHOLE — a
26442656
// pointer, not a copy of its key set (objectui#7823).

‎packages/plugin-tree/README.md‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,19 @@ user can create. To render a tree from authored metadata, write the
7777

7878
Host config is **not** untyped config. A block a host stores and re-writes is a
7979
contract, so the per-view `tree` block is exported from `@object-ui/types` and
80-
is the single declaration of that shape — the renderer imports it rather than
81-
keeping a private copy (objectui#8253, ruled 2026-09-07):
80+
the renderer imports it rather than keeping a private copy (objectui#8253,
81+
ruled 2026-09-07).
82+
83+
⚠️ It is **not the single declaration of that shape**, and saying so was itself
84+
the defect objectui#8841 fixed. `@objectstack/spec` owns this block — it
85+
declares it as `TreeConfig` and hangs it on `ListView.tree` — and
86+
`@object-ui/types` already publishes it a second way, derived, as
87+
`ListViewSchema['tree']`. `TreeViewConfig` is now a **derivation of the
88+
protocol's block** rather than a copy of it — in `packages/types/src/views.ts`
89+
it is a one-line alias of `NonNullable<ListView['tree']>`, taken from
90+
`@objectstack/spec/ui`. So the accurate claim is the narrower one: this is the
91+
name a host writes against, and it tracks the protocol by construction rather
92+
than by anyone remembering to update it.
8293

8394
```ts
8495
import type { TreeViewConfig } from '@object-ui/types';
@@ -98,9 +109,18 @@ Annotating the block is what turns a typo into a diagnostic: `parentFeild` used
98109
to be stored, read by nobody and reported by nothing, because the `views` entry
99110
admits any key. Against this type it is a compile error.
100111

101-
`titleField` is also declared — a legacy second rung for `labelField`, kept
102-
because the console's own composition still reads it. Prefer `labelField`,
103-
which wins wherever both are present.
112+
⛔ `titleField` is **not** part of this block. objectui#8253 declared it as a
113+
legacy second rung for `labelField`; `@objectstack/spec@17.4.0` refuses
114+
`tree.titleField` by name (`TreeConfigSchema` is strict since spec #15469), so
115+
declaring it published a key the protocol rejects — an author who followed this
116+
type was refused at publish. objectui#8841 removed it.
117+
118+
The renderers still *tolerate* a `titleField` already stored on a view record:
119+
`plugin-view`, `plugin-list` and the console's own composition each fall back to
120+
it when `labelField` is absent, so nothing that renders today stops rendering.
121+
Those reads are untyped tolerance awaiting a follow-up, ⛔ not a declaration —
122+
write `labelField`, which is the protocol's spelling and wins wherever both are
123+
present.
104124

105125
⛔ This does not make `tree` an authorable view type. objectui#5321 is
106126
unchanged: the block is written by a **host**, never by a document author, and

‎packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts‎

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@
2626
* maintainer 「同意」), option (a): `packages/types` exports the config, the
2727
* module-local copy becomes an import of it, and ⛔ there is no second copy.
2828
*
29+
* ## What objectui#8841 changed here
30+
*
31+
* objectui#8253 shipped that export as a hand-written interface — a copy of the
32+
* protocol's `ListView.tree` block under a second name — and the copy declared a
33+
* fifth key, `titleField`, that `@objectstack/spec@17.4.0` REFUSES there. The
34+
* pins in this file did not catch it because the census was a LITERAL KEY LIST
35+
* maintained beside the type: the list was written to match the drift, so it
36+
* agreed with the defect. objectui#8841 re-derives the type from the protocol
37+
* and re-pins the census as PARITY WITH THE PROTOCOL — plus a runtime leg that
38+
* reads the installed `TreeConfigSchema` by content, because a compile-time pin
39+
* on a derived alias can only restate its own derivation.
40+
*
2941
* ## Why the import below says `@object-ui/types` and not `../../types/src`
3042
*
3143
* This is the load-bearing part of the pin, not a style choice. This package's
@@ -57,6 +69,16 @@ import { describe, it, expect } from 'vitest';
5769
// relative path into `packages/types/src`.
5870
import type { TreeViewConfig } from '@object-ui/types';
5971

72+
// The PROTOCOL's own declaration of this block, imported for the parity pins
73+
// below (objectui#8841). `@object-ui/types` derives `TreeViewConfig` from
74+
// `ListView['tree']`, so this import is the other end of that derivation and
75+
// the only thing a census can honestly be total over.
76+
import { TreeConfigSchema } from '@objectstack/spec/ui';
77+
import type { ListView as SpecListView } from '@objectstack/spec/ui';
78+
79+
/** The protocol's `ListView.tree` block. */
80+
type SpecTreeConfig = NonNullable<SpecListView['tree']>;
81+
6082
/* -------------------------------------------------------------------------- */
6183
/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */
6284
/* -------------------------------------------------------------------------- */
@@ -66,49 +88,82 @@ type Equal<A, B> =
6688
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
6789
type IsAny<T> = 0 extends 1 & T ? true : false;
6890

69-
/**
70-
* The keys `getTreeConfig` in `ObjectTree.tsx` reads off this block, and the
71-
* ones the ruling says the type carries EXACTLY. Pinned as a `keyof` equality
72-
* rather than a bag of `HasKey` checks, because equality is the only spelling
73-
* that fails in BOTH directions — a key added here without a reader is as much
74-
* a defect as a key removed from under one.
75-
*/
76-
type DeclaredKey =
77-
| 'parentField'
78-
| 'labelField'
79-
| 'titleField'
80-
| 'fields'
81-
| 'defaultExpandedDepth';
82-
83-
describe('objectui#8253 — TreeViewConfig is reachable through @object-ui/types', () => {
91+
describe('objectui#8253/#8841 — TreeViewConfig is the protocol\'s block, reachable through @object-ui/types', () => {
8492
it('is pinned at compile time', () => {
8593
// Non-vacuity. `keyof any` is `string | number | symbol`, and every
8694
// `Equal<…>` below would report whatever an `any` made convenient. If the
8795
// import ever resolves to `any` — a broken export map degrades exactly
8896
// this way — this line fails FIRST and names the reason.
8997
type _ConfigIsReal = Assert<Equal<IsAny<TreeViewConfig>, false>>;
98+
type _SpecConfigIsReal = Assert<Equal<IsAny<SpecTreeConfig>, false>>;
99+
100+
// ⭐ THE CENSUS, and objectui#8841 changed what it is made of. It used to be
101+
// a LITERAL key list maintained here by hand — and a hand-maintained list
102+
// is exactly what let `titleField` through: the list was updated to match
103+
// the drift, so the pin agreed with the defect and stayed green while the
104+
// published type accepted a key `@objectstack/spec@17.4.0` refuses on
105+
// `ListView.tree`. A census can only be total over something it does not
106+
// also author.
107+
//
108+
// So it is PARITY WITH THE PROTOCOL now. Structural equality, not `extends`:
109+
// a hand-written twin passes an assignability check in both directions and
110+
// would defeat the derivation entirely.
111+
type _ParityWithSpec = Assert<Equal<TreeViewConfig, SpecTreeConfig>>;
112+
113+
// FIRING CONTROL for the line above. An `Equal<…>` loosened until it cannot
114+
// report `false` reads exactly like one that still works. This feeds it the
115+
// near-miss that actually shipped — the spec's block plus `titleField` — and
116+
// requires it to say `false`.
117+
type _ParityCanFail = Assert<Equal<Equal<TreeViewConfig, SpecTreeConfig & { titleField?: string }>, false>>;
90118

91-
// The census, total in both directions. This ALSO refuses an index
92-
// signature: `[key: string]: any` would put `string` into `keyof` and this
93-
// equality would fail. That matters more than it looks — an index
94-
// signature here would re-open the exact hole the card was filed for, by
95-
// making every misspelling assignable again.
96-
type _Census = Assert<Equal<keyof TreeViewConfig, DeclaredKey>>;
119+
// The defect, pinned by name so its return is reported as itself rather
120+
// than as an anonymous parity failure.
121+
type _TitleFieldIsGone = Assert<Equal<'titleField' extends keyof TreeViewConfig ? true : false, false>>;
122+
123+
// ⛔ No index signature. This does NOT fall out of parity above: were the
124+
// protocol's block `.passthrough()` again (it was, at 17.3.0), both sides
125+
// would carry `[key: string]: unknown` and parity would still hold while
126+
// every misspelling became assignable again. This line is what pins the
127+
// STRICTNESS the card depends on, and it is the line that fires if the
128+
// installed `@objectstack/spec` ever drops below 17.4.0.
129+
type _NoIndexSignature = Assert<Equal<string extends keyof TreeViewConfig ? true : false, false>>;
97130

98131
// Every key is optional: a host writes the subset it means. `Partial<T>`
99132
// is structurally identical to `T` only when nothing is required.
100133
type _AllOptional = Assert<Equal<TreeViewConfig, Partial<TreeViewConfig>>>;
101134

102-
// Per-key types, read against the sibling node schema's spelling.
103-
type _ParentField = Assert<Equal<TreeViewConfig['parentField'], string | undefined>>;
104-
type _LabelField = Assert<Equal<TreeViewConfig['labelField'], string | undefined>>;
105-
type _TitleField = Assert<Equal<TreeViewConfig['titleField'], string | undefined>>;
106-
type _Fields = Assert<Equal<TreeViewConfig['fields'], string[] | undefined>>;
107-
type _Depth = Assert<Equal<TreeViewConfig['defaultExpandedDepth'], number | undefined>>;
108-
109135
expect(true).toBe(true);
110136
});
111137

138+
it('parity is a measurement, not a tautology: the protocol\'s RUNTIME shape agrees', () => {
139+
// The type-level pins above are all compile-time, and a compile-time pin on
140+
// a derived alias can only ever restate the derivation. This is the leg that
141+
// reads the installed artifact instead: the same `TreeConfigSchema` the
142+
// publisher parses against, by content.
143+
//
144+
// ⛔ Deliberately NOT a literal key-set equality. Freezing the protocol's
145+
// key list here would make a benign spec addition red in objectui and would
146+
// put a second hand-maintained list back in the file this card emptied. What
147+
// is pinned is the DEFECT and the INSTRUMENT, not the census.
148+
const keys = Object.keys(TreeConfigSchema.shape);
149+
expect(keys).not.toContain('titleField');
150+
151+
// FIRING CONTROL for the line above, same instrument: a key the protocol
152+
// DOES declare is found, so the zero is a reading about `titleField` and
153+
// not about an empty shape or an import that resolved to a stub.
154+
expect(keys).toContain('parentField');
155+
156+
// FIRING CONTROL — the schema accepts what it declares, so the refusal
157+
// below is a statement about the KEY and not about a schema that refuses
158+
// everything.
159+
expect(TreeConfigSchema.safeParse({ parentField: 'parent_id' }).success).toBe(true);
160+
161+
// The refusal the card was filed for, taken from the protocol itself.
162+
const refused = TreeConfigSchema.safeParse({ titleField: 'name' });
163+
expect(refused.success).toBe(false);
164+
expect(JSON.stringify(refused.error?.issues)).toContain('titleField');
165+
});
166+
112167
it('refuses the misspelling the card was filed for, at compile time', () => {
113168
// ⭐ A FRESH object literal is the right instrument HERE, and it is the
114169
// wrong one in `types/src/__tests__/menu-item-union.test.ts` — worth

0 commit comments

Comments
 (0)