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
58 changes: 58 additions & 0 deletions .changeset/8841-tree-view-config-derive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@object-ui/types': minor
'@object-ui/plugin-tree': minor
'@object-ui/plugin-view': patch
'@object-ui/app-shell': patch
---

Derive `TreeViewConfig` from `@objectstack/spec` and drop `titleField`, the key the
protocol refuses on `ListView.tree` (objectui#8841).

**What was wrong.** `@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`. `@objectstack/spec@17.4.0` refuses that key
there by name: `TreeConfigSchema` is a `strictObject` since spec #15469 closed the
`.passthrough()` window 17.3.0 left open. So this package's published face accepted what
the contract rejects, and an author who followed `@object-ui/types` was refused at
publish with `Unrecognized key(s) on this tree configuration: 'titleField'`. The copy was
invisible to `scripts/check-spec-symbol-derivation.mjs`, which matches spec symbols BY
NAME — a hand copy renamed away from the spec's symbol has nothing for its rule 1 to
match (objectui#4592's recorded blind spot).

**The grades, and why.**

- `@object-ui/types` — **minor**. `TreeViewConfig` is now
`NonNullable<ListView['tree']>` from `@objectstack/spec/ui`, and `titleField` is
removed from a **published** exported type. That is breaking for a producer that
annotates a `tree` block carrying the key; per this repo's version-alignment policy
(AGENTS.md — objectui's major tracks `@objectstack`'s) objectui's own breaking changes
ship as `minor` with the breaking semantics stated here. The precedent is the same
shape: `Remove the retired striped / bordered / virtualScroll list-view surface`
(`@object-ui/types` 17.6.0, minor) propagated a spec-side retirement into this package
the same way.
- `@object-ui/plugin-tree` — **minor**. `getTreeConfig`'s `labelField` chain loses its
third rung, `?? schema.titleField`. That rung read the flattened **node**, never the
block, and `titleField` is declared on neither face — not on `ObjectTreeSchema` (the TS
interface or its zod mirror) and not on the protocol's `ListView.tree`. It is a runtime
behaviour change, graded like one. The README's claim that this block is "the single
declaration of that shape" is corrected in the same stroke: the protocol owns it, and
this package publishes it derived.
- `@object-ui/plugin-view` — **patch**. `ObjectViewProps.views[n].tree` still resolves to
`TreeViewConfig`; what it admits narrows with the type. Type-only, no runtime change.
- `@object-ui/app-shell` — **patch**. The console's `tree` composition keeps both rungs;
only the second one's annotation changes (see below). No runtime change.

**The tolerant reads are kept, and deliberately left undeclared.** Three
`labelField || titleField` dual-reads survive — `plugin-view`'s and `plugin-list`'s
`'tree'` branches and the console's own composition in `app-shell` — so a view record
that already stores `tree.titleField` keeps resolving exactly as before, and
objectui#6557's pin on that rung stays green. They read through `any` now; the console's
canonical rung stays annotated `TreeViewConfig` while its legacy rung is not, because
casting it to a type that no longer carries the key cannot compile and re-declaring the
key locally would fossilise a renderer-side alias into a second contract — the AGENTS.md
#0.1 defect this change undoes. Retiring those three reads is a follow-up.

**Migration.** A host that writes `tree.titleField` should write `tree.labelField`, which
is the protocol's spelling and already wins wherever both are present. Nothing that
renders today stops rendering; what changes is that the key is now reported at compile
time by the same face that will refuse it at publish, instead of only at publish.
26 changes: 19 additions & 7 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2627,18 +2627,30 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
// auto-detects when omitted.
//
// Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253):
// `viewDef` is `Record<string, any>`, so both rungs below were
// `any` property accesses and a misspelling was invisible. This
// is the half of objectui#7559 a declaration CAN close, on the
// one block that now has a declaration to close it with — it
// does NOT make a missing rung visible, which is what the census
// pin (`ObjectView.relayRungCensus-7559.test.ts`) is for.
// `viewDef` is `Record<string, any>`, so the canonical rung
// below was an `any` property access and a misspelling was
// invisible. This is the half of objectui#7559 a declaration CAN
// close, on the one block that now has a declaration to close it
// with — it does NOT make a missing rung visible, which is what
// the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is
// for.
//
// ⚠️ The cast is repeated per rung rather than hoisted into a
// local: objectui#6557's convergence pin reads these seam lines
// out of this file and requires each to name `viewDef` itself.
//
// ⛔ The `titleField` rung is deliberately NOT cast (objectui#8841).
// `TreeViewConfig` is now the spec's `ListView.tree` block, and
// `@objectstack/spec@17.4.0` refuses `titleField` there by name,
// so casting to it would not compile and re-declaring the key
// locally would fossilise a renderer-side alias into a second
// contract — AGENTS.md #0.1, and the defect objectui#8841 exists
// to undo. The rung stays as an UNDECLARED tolerant fallback,
// read through `any`, kept so already-stored view records keep
// resolving and so objectui#6557's pin on it stays honest. Its
// retirement is a follow-up, ⛔ not a rider here.
...((viewDef.tree as TreeViewConfig | undefined) || {}),
labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || (viewDef.tree as TreeViewConfig | undefined)?.titleField || 'name',
labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || viewDef.tree?.titleField || 'name',
},
// The chart block the view DECLARED, forwarded WHOLE — a
// pointer, not a copy of its key set (objectui#7823).
Expand Down
30 changes: 25 additions & 5 deletions packages/plugin-tree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,19 @@ user can create. To render a tree from authored metadata, write the

Host config is **not** untyped config. A block a host stores and re-writes is a
contract, so the per-view `tree` block is exported from `@object-ui/types` and
is the single declaration of that shape — the renderer imports it rather than
keeping a private copy (objectui#8253, ruled 2026-09-07):
the renderer imports it rather than keeping a private copy (objectui#8253,
ruled 2026-09-07).

⚠️ It is **not the single declaration of that shape**, and saying so was itself
the defect objectui#8841 fixed. `@objectstack/spec` owns this block — it
declares it as `TreeConfig` and hangs it on `ListView.tree` — and
`@object-ui/types` already publishes it a second way, derived, as
`ListViewSchema['tree']`. `TreeViewConfig` is now a **derivation of the
protocol's block** rather than a copy of it — in `packages/types/src/views.ts`
it is a one-line alias of `NonNullable<ListView['tree']>`, taken from
`@objectstack/spec/ui`. So the accurate claim is the narrower one: this is the
name a host writes against, and it tracks the protocol by construction rather
than by anyone remembering to update it.

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

`titleField` is also declared — a legacy second rung for `labelField`, kept
because the console's own composition still reads it. Prefer `labelField`,
which wins wherever both are present.
⛔ `titleField` is **not** part of this block. objectui#8253 declared it as a
legacy second rung for `labelField`; `@objectstack/spec@17.4.0` refuses
`tree.titleField` by name (`TreeConfigSchema` is strict since spec #15469), so
declaring it published a key the protocol rejects — an author who followed this
type was refused at publish. objectui#8841 removed it.

The renderers still *tolerate* a `titleField` already stored on a view record:
`plugin-view`, `plugin-list` and the console's own composition each fall back to
it when `labelField` is absent, so nothing that renders today stops rendering.
Those reads are untyped tolerance awaiting a follow-up, ⛔ not a declaration —
write `labelField`, which is the protocol's spelling and wins wherever both are
present.

⛔ This does not make `tree` an authorable view type. objectui#5321 is
unchanged: the block is written by a **host**, never by a document author, and
Expand Down
111 changes: 83 additions & 28 deletions packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@
* maintainer 「同意」), option (a): `packages/types` exports the config, the
* module-local copy becomes an import of it, and ⛔ there is no second copy.
*
* ## What objectui#8841 changed here
*
* objectui#8253 shipped that export 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 there. The
* pins in this file did not catch it because the census was a LITERAL KEY LIST
* maintained beside the type: the list was written to match the drift, so it
* agreed with the defect. objectui#8841 re-derives the type from the protocol
* and re-pins the census as PARITY WITH THE PROTOCOL — plus a runtime leg that
* reads the installed `TreeConfigSchema` by content, because a compile-time pin
* on a derived alias can only restate its own derivation.
*
* ## Why the import below says `@object-ui/types` and not `../../types/src`
*
* This is the load-bearing part of the pin, not a style choice. This package's
Expand Down Expand Up @@ -57,6 +69,16 @@ import { describe, it, expect } from 'vitest';
// relative path into `packages/types/src`.
import type { TreeViewConfig } from '@object-ui/types';

// The PROTOCOL's own declaration of this block, imported for the parity pins
// below (objectui#8841). `@object-ui/types` derives `TreeViewConfig` from
// `ListView['tree']`, so this import is the other end of that derivation and
// the only thing a census can honestly be total over.
import { TreeConfigSchema } from '@objectstack/spec/ui';
import type { ListView as SpecListView } from '@objectstack/spec/ui';

/** The protocol's `ListView.tree` block. */
type SpecTreeConfig = NonNullable<SpecListView['tree']>;

/* -------------------------------------------------------------------------- */
/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */
/* -------------------------------------------------------------------------- */
Expand All @@ -66,49 +88,82 @@ type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;

/**
* The keys `getTreeConfig` in `ObjectTree.tsx` reads off this block, and the
* ones the ruling says the type carries EXACTLY. Pinned as a `keyof` equality
* rather than a bag of `HasKey` checks, because equality is the only spelling
* that fails in BOTH directions — a key added here without a reader is as much
* a defect as a key removed from under one.
*/
type DeclaredKey =
| 'parentField'
| 'labelField'
| 'titleField'
| 'fields'
| 'defaultExpandedDepth';

describe('objectui#8253 — TreeViewConfig is reachable through @object-ui/types', () => {
describe('objectui#8253/#8841 — TreeViewConfig is the protocol\'s block, reachable through @object-ui/types', () => {
it('is pinned at compile time', () => {
// Non-vacuity. `keyof any` is `string | number | symbol`, and every
// `Equal<…>` below would report whatever an `any` made convenient. If the
// import ever resolves to `any` — a broken export map degrades exactly
// this way — this line fails FIRST and names the reason.
type _ConfigIsReal = Assert<Equal<IsAny<TreeViewConfig>, false>>;
type _SpecConfigIsReal = Assert<Equal<IsAny<SpecTreeConfig>, false>>;

// ⭐ THE CENSUS, and objectui#8841 changed what it is made of. It used to be
// a LITERAL key list maintained here by hand — and a hand-maintained list
// is exactly what let `titleField` through: the list was updated to match
// the drift, so the pin agreed with the defect and stayed green while the
// published type accepted a key `@objectstack/spec@17.4.0` refuses on
// `ListView.tree`. A census can only be total over something it does not
// also author.
//
// So it is PARITY WITH THE PROTOCOL now. Structural equality, not `extends`:
// a hand-written twin passes an assignability check in both directions and
// would defeat the derivation entirely.
type _ParityWithSpec = Assert<Equal<TreeViewConfig, SpecTreeConfig>>;

// FIRING CONTROL for the line above. An `Equal<…>` loosened until it cannot
// report `false` reads exactly like one that still works. This feeds it the
// near-miss that actually shipped — the spec's block plus `titleField` — and
// requires it to say `false`.
type _ParityCanFail = Assert<Equal<Equal<TreeViewConfig, SpecTreeConfig & { titleField?: string }>, false>>;

// The census, total in both directions. This ALSO refuses an index
// signature: `[key: string]: any` would put `string` into `keyof` and this
// equality would fail. That matters more than it looks — an index
// signature here would re-open the exact hole the card was filed for, by
// making every misspelling assignable again.
type _Census = Assert<Equal<keyof TreeViewConfig, DeclaredKey>>;
// The defect, pinned by name so its return is reported as itself rather
// than as an anonymous parity failure.
type _TitleFieldIsGone = Assert<Equal<'titleField' extends keyof TreeViewConfig ? true : false, false>>;

// ⛔ No index signature. This does NOT fall out of parity above: were the
// protocol's block `.passthrough()` again (it was, at 17.3.0), both sides
// would carry `[key: string]: unknown` and parity would still hold while
// every misspelling became assignable again. This line is what pins the
// STRICTNESS the card depends on, and it is the line that fires if the
// installed `@objectstack/spec` ever drops below 17.4.0.
type _NoIndexSignature = Assert<Equal<string extends keyof TreeViewConfig ? true : false, false>>;

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

// Per-key types, read against the sibling node schema's spelling.
type _ParentField = Assert<Equal<TreeViewConfig['parentField'], string | undefined>>;
type _LabelField = Assert<Equal<TreeViewConfig['labelField'], string | undefined>>;
type _TitleField = Assert<Equal<TreeViewConfig['titleField'], string | undefined>>;
type _Fields = Assert<Equal<TreeViewConfig['fields'], string[] | undefined>>;
type _Depth = Assert<Equal<TreeViewConfig['defaultExpandedDepth'], number | undefined>>;

expect(true).toBe(true);
});

it('parity is a measurement, not a tautology: the protocol\'s RUNTIME shape agrees', () => {
// The type-level pins above are all compile-time, and a compile-time pin on
// a derived alias can only ever restate the derivation. This is the leg that
// reads the installed artifact instead: the same `TreeConfigSchema` the
// publisher parses against, by content.
//
// ⛔ Deliberately NOT a literal key-set equality. Freezing the protocol's
// key list here would make a benign spec addition red in objectui and would
// put a second hand-maintained list back in the file this card emptied. What
// is pinned is the DEFECT and the INSTRUMENT, not the census.
const keys = Object.keys(TreeConfigSchema.shape);
expect(keys).not.toContain('titleField');

// FIRING CONTROL for the line above, same instrument: a key the protocol
// DOES declare is found, so the zero is a reading about `titleField` and
// not about an empty shape or an import that resolved to a stub.
expect(keys).toContain('parentField');

// FIRING CONTROL — the schema accepts what it declares, so the refusal
// below is a statement about the KEY and not about a schema that refuses
// everything.
expect(TreeConfigSchema.safeParse({ parentField: 'parent_id' }).success).toBe(true);

// The refusal the card was filed for, taken from the protocol itself.
const refused = TreeConfigSchema.safeParse({ titleField: 'name' });
expect(refused.success).toBe(false);
expect(JSON.stringify(refused.error?.issues)).toContain('titleField');
});

it('refuses the misspelling the card was filed for, at compile time', () => {
// ⭐ A FRESH object literal is the right instrument HERE, and it is the
// wrong one in `types/src/__tests__/menu-item-union.test.ts` — worth
Expand Down
Loading
Loading