Skip to content

Commit 5383fa6

Browse files
os-warrenclaude
andauthored
feat(spec,lint): ListView react-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11695)
* feat(spec,lint): converge ListView react-tier vocabulary on the metadata-tier spelling, deprecate-first Canonical: data={{ provider: 'object', object }} and type (ListViewSchema's own props, surfaced via dataProps). objectName / viewType stay published as deprecated aliases for the deprecation window; the lint warns on each use (react-prop-deprecated), accepts either spelling for the required binding, and resolves field props against the object bound by whichever is present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * fix(lint): export REACT_PROP_DEPRECATED from the barrel (rule-id-barrel-exports gate) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09f9361 commit 5383fa6

9 files changed

Lines changed: 400 additions & 58 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). `<ListView>`'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying `<ListView>`'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `<ObjectForm>` / `<ObjectChart>` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window.

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export {
149149
REACT_CHART_DRILLDOWN_INVALID,
150150
REACT_BLOCK_NEEDS_RECORD_CONTEXT,
151151
REACT_PAGE_SOURCE_UNPARSEABLE,
152+
REACT_PROP_DEPRECATED,
152153
} from './validate-react-page-props.js';
153154
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
154155

packages/lint/src/validate-react-page-props.test.ts

Lines changed: 133 additions & 34 deletions
Large diffs are not rendered by default.

packages/lint/src/validate-react-page-props.ts

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,29 @@ const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) :
122122
interface BlockSpec {
123123
requiredBindings: string[];
124124
knownProps: Set<string>;
125+
/**
126+
* [#11284] Deprecated overlay spellings, read from the contract rather than
127+
* restated: prop name → its canonical replacement + the authoring note the
128+
* warning quotes. A `required` prop that is deprecated is a required
129+
* BINDING, not a required spelling — the canonical `replacedBy` prop
130+
* satisfies it (see the missing-required check below).
131+
*/
132+
deprecated: Map<string, { replacedBy: string; note: string }>;
125133
}
126134
const BLOCKS: Map<string, BlockSpec> = new Map(
127-
(REACT_BLOCKS as Array<{ tag: string; interactions: Array<{ name: string; required?: boolean }> }>).map((b) => [
135+
(
136+
REACT_BLOCKS as Array<{
137+
tag: string;
138+
interactions: Array<{ name: string; required?: boolean; deprecated?: { replacedBy: string; note: string } }>;
139+
}>
140+
).map((b) => [
128141
b.tag,
129142
{
130143
requiredBindings: b.interactions.filter((i) => i.required).map((i) => i.name),
131144
knownProps: new Set(b.interactions.map((i) => i.name)),
145+
deprecated: new Map(
146+
b.interactions.filter((i) => i.deprecated).map((i) => [i.name, i.deprecated!]),
147+
),
132148
},
133149
]),
134150
);
@@ -280,6 +296,15 @@ function filterAttrValue(tsc: typeof ts, sf: ts.SourceFile, attr: ts.JsxAttribut
280296
*/
281297
export const REACT_PAGE_SOURCE_UNPARSEABLE = 'react-page-source-unparseable';
282298

299+
/**
300+
* [#11284] A prop written in a deprecated react-tier spelling (maintainer
301+
* ruling 2026-08-23: the react tier converges on the metadata-tier
302+
* vocabulary, deprecate-first). Warning, never error: the old spelling keeps
303+
* working for the whole deprecation window — this is the loud half of
304+
* "alias + loud deprecation", same shape as `approval-approver-type-deprecated`.
305+
*/
306+
export const REACT_PROP_DEPRECATED = 'react-prop-deprecated';
307+
283308
export const REACT_CHART_FIELD_UNKNOWN = 'react-chart-field-unknown';
284309
export const REACT_CHART_FIELD_UNPROVISIONED = 'react-chart-field-unprovisioned';
285310
export const REACT_CHART_AGGREGATE_INVALID = 'react-chart-aggregate-invalid';
@@ -844,6 +869,30 @@ function reactFieldRefs(
844869
* (`page-field-unknown`): the same question, asked of the same component, with
845870
* the same fix.
846871
*/
872+
/**
873+
* The object a block is bound to, canonical spelling first.
874+
*
875+
* [#11284] ListView's canonical binding is the metadata-tier data source —
876+
* `data={{ provider: 'object', object }}` — with `objectName` the deprecated
877+
* alias for the deprecation window. Canonical wins when both are present,
878+
* mirroring the one-directional fold objectui's `normalizeListViewSchema`
879+
* applies at the component boundary. A non-static `data` (a variable, a
880+
* spread-borne value) is `NOT_STATIC` here and falls back to `objectName` —
881+
* unresolvable is not wrong (ADR-0072 D1). ListView only: on `<ObjectChart>`
882+
* the `data` prop is a static ROW ARRAY, and `<ObjectForm>`'s object binding
883+
* is not converged by this step.
884+
*/
885+
function boundObjectName(tag: string, values: ReadonlyMap<string, unknown>): string | undefined {
886+
if (tag === 'ListView') {
887+
const data = values.get('data');
888+
if (isRec(data) && data.provider === 'object') {
889+
const obj = strOf(data.object);
890+
if (obj) return obj;
891+
}
892+
}
893+
return strOf(values.get('objectName'));
894+
}
895+
847896
function checkBlockFieldProps(
848897
tag: string,
849898
values: ReadonlyMap<string, unknown>,
@@ -855,7 +904,7 @@ function checkBlockFieldProps(
855904
// one, so it must hand over the same index rather than answer differently.
856905
unprovisionedAnchors?: ReadonlyMap<string, ReadonlySet<string>>,
857906
): ReactPropFinding[] {
858-
const objectName = strOf(values.get('objectName'));
907+
const objectName = boundObjectName(tag, values);
859908
const out: PageFieldFinding[] = [];
860909

861910
const spec = REACT_FIELD_SPECS[tag];
@@ -1078,18 +1127,37 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
10781127
}
10791128
if (!hasSpread) {
10801129
for (const req of block.requiredBindings) {
1081-
if (!used.has(req)) {
1082-
findings.push({
1083-
severity: 'error',
1084-
rule: 'react-prop-missing-required',
1085-
where, path,
1086-
message: `<${tag}> is missing the required prop "${req}".`,
1087-
hint: `Pass ${req}={…}. See the react-tier component contract.`,
1088-
});
1089-
}
1130+
if (used.has(req)) continue;
1131+
// [#11284] A required prop that is DEPRECATED requires the
1132+
// binding, not the spelling: the canonical replacement satisfies
1133+
// it, so the new vocabulary is accepted without the old one.
1134+
const dep = block.deprecated.get(req);
1135+
if (dep && used.has(dep.replacedBy)) continue;
1136+
findings.push({
1137+
severity: 'error',
1138+
rule: 'react-prop-missing-required',
1139+
where, path,
1140+
message: dep
1141+
? `<${tag}> is missing its "${req}" binding — pass ${dep.replacedBy}={…} (canonical) or ${req}={…} (deprecated).`
1142+
: `<${tag}> is missing the required prop "${req}".`,
1143+
hint: dep ? dep.note : `Pass ${req}={…}. See the react-tier component contract.`,
1144+
});
10901145
}
10911146
}
10921147
for (const u of used) {
1148+
// [#11284] Deprecate-first: the old spelling keeps working, and
1149+
// every use says so — the contract's note names the canonical
1150+
// metadata-tier spelling to write instead.
1151+
const dep = block.deprecated.get(u);
1152+
if (dep) {
1153+
findings.push({
1154+
severity: 'warning',
1155+
rule: REACT_PROP_DEPRECATED,
1156+
where, path,
1157+
message: `<${tag}> prop "${u}" is the deprecated spelling of the metadata-tier "${dep.replacedBy}" and is removed after the deprecation window (#11284).`,
1158+
hint: dep.note,
1159+
});
1160+
}
10931161
const near = nearestKnown(u, block.knownProps);
10941162
if (near) {
10951163
findings.push({
@@ -1119,7 +1187,9 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
11191187
findings.push(
11201188
...checkSearchableFieldList(
11211189
values.get('searchableFields'),
1122-
strOf(values.get('objectName')),
1190+
// [#11284] canonical `data={{ provider: 'object', object }}`
1191+
// first, deprecated `objectName` as the window fallback.
1192+
boundObjectName(tag, values),
11231193
searchTargets,
11241194
where,
11251195
`${path} › searchableFields`,

packages/spec/scripts/build-react-blocks-contract.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ const clip = (s: unknown, n = 160): string => {
5555
return t.length > n ? t.slice(0, n - 1) + '…' : t;
5656
};
5757

58-
interface Prop { name: string; type: string; kind: string; required: boolean; description: string }
58+
interface Prop {
59+
name: string;
60+
type: string;
61+
kind: string;
62+
required: boolean;
63+
description: string;
64+
/** #11284 deprecate-first: canonical replacement + authoring note, passed through from the overlay. */
65+
deprecated?: { replacedBy: string; note: string };
66+
}
5967

6068
function dataProps(schema: any, allow?: string[]): Prop[] {
6169
let js: any;
@@ -94,7 +102,16 @@ function dataProps(schema: any, allow?: string[]): Prop[] {
94102
}
95103

96104
function mergeProps(dataPs: Prop[], overlay: ReactInteractionProp[]): Prop[] {
97-
const out: Prop[] = overlay.map((o) => ({ name: o.name, type: o.type, kind: o.kind, required: !!o.required, description: o.description }));
105+
const out: Prop[] = overlay.map((o) => ({
106+
name: o.name,
107+
type: o.type,
108+
kind: o.kind,
109+
required: !!o.required,
110+
description: o.description,
111+
// #11284 deprecate-first: machine consumers see the canonical replacement;
112+
// the human-facing "[DEPRECATED → …]" marker rides the description text.
113+
...(o.deprecated ? { deprecated: o.deprecated } : {}),
114+
}));
98115
const seen = new Set(out.map((p) => p.name));
99116
for (const d of dataPs) if (!seen.has(d.name)) out.push(d);
100117
return out;

packages/spec/src/ui/react-blocks.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,82 @@ describe('REACT_BLOCKS — the record:* family is out (#4413)', () => {
129129
expect(tags).toContain('ObjectForm');
130130
});
131131
});
132+
133+
/**
134+
* #11284 — the react tier converges on the metadata-tier vocabulary,
135+
* deprecate-first (maintainer ruling 2026-08-23, recorded on-card). This step
136+
* declares the canonical spellings and keeps the old ones as deprecated
137+
* aliases; REMOVAL is a later card, so these pins hold the window open in both
138+
* directions: the canonical props must be published, and the aliases must not
139+
* quietly disappear before their card.
140+
*/
141+
describe('REACT_BLOCKS — deprecate-first vocabulary convergence (#11284)', () => {
142+
it('every curated dataProps entry resolves to a real schema prop', () => {
143+
// `build-react-blocks-contract`'s allow-list FILTERS the schema's props, so
144+
// a curated name the schema does not declare is silently dropped from the
145+
// published contract — the failure mode would be a canonical spelling that
146+
// never actually ships. Pin the subset relation for every block.
147+
for (const b of REACT_BLOCKS) {
148+
if (!b.schema || !b.dataProps) continue;
149+
const schemaProps = new Set(schemaPropNames(b.schema));
150+
const missing = b.dataProps.filter((p) => !schemaProps.has(p));
151+
expect(missing, `<${b.tag}> dataProps not on its spec schema`).toEqual([]);
152+
}
153+
});
154+
155+
it('a deprecated overlay prop names a real canonical prop on the same block, and says so in its description', () => {
156+
for (const b of REACT_BLOCKS) {
157+
const names = new Set([
158+
...b.interactions.map((i) => i.name),
159+
...(b.schema ? schemaPropNames(b.schema) : []),
160+
]);
161+
for (const i of b.interactions) {
162+
if (!i.deprecated) continue;
163+
expect(
164+
names.has(i.deprecated.replacedBy),
165+
`<${b.tag}> ${i.name} → "${i.deprecated.replacedBy}" names no prop on the block`,
166+
).toBe(true);
167+
// The established textual convention (FormViewSchema.groups /
168+
// drawerWidth): the marker travels in the published description.
169+
expect(
170+
i.description.startsWith('[DEPRECATED'),
171+
`<${b.tag}> ${i.name} description must carry the [DEPRECATED → …] marker`,
172+
).toBe(true);
173+
}
174+
}
175+
});
176+
177+
it('ListView: objectName→data and viewType→type, canonical props surfaced, aliases still published', () => {
178+
const lv = REACT_BLOCKS.find((b) => b.tag === 'ListView')!;
179+
const dep = Object.fromEntries(
180+
lv.interactions.filter((i) => i.deprecated).map((i) => [i.name, i.deprecated!.replacedBy]),
181+
);
182+
// The ruled mapping, exactly — objectui#2890 A6 (`objectName` →
183+
// `data: { provider: 'object', object }`) and its sibling `viewType` → `type`.
184+
expect(dep).toEqual({ objectName: 'data', viewType: 'type' });
185+
expect(lv.dataProps).toContain('type');
186+
expect(lv.dataProps).toContain('data');
187+
// Deprecate-first: the aliases stay for the whole window.
188+
const names = lv.interactions.map((i) => i.name);
189+
expect(names).toContain('objectName');
190+
expect(names).toContain('viewType');
191+
// The binding requirement survives the deprecation (the lint lets the
192+
// canonical `data` prop satisfy it — see validate-react-page-props).
193+
expect(lv.interactions.find((i) => i.name === 'objectName')!.required).toBe(true);
194+
});
195+
196+
it('ObjectForm and ObjectChart objectName are NOT converged by this step', () => {
197+
// ObjectForm: objectui#2890 Scope B says its spec counterpart is "not 1:1"
198+
// and wants an audit before any swap. ObjectChart: chart.zod.ts's own
199+
// guidance declares the `objectName` PROP the sanctioned react binding —
200+
// the metadata tier binds charts through a dashboard `dataset`, a
201+
// different mechanism, so there is no metadata-tier spelling to adopt.
202+
// Extending the convergence to either is a new ruling, not a drive-by.
203+
for (const tag of ['ObjectForm', 'ObjectChart']) {
204+
const b = REACT_BLOCKS.find((x) => x.tag === tag)!;
205+
const objectName = b.interactions.find((i) => i.name === 'objectName')!;
206+
expect(objectName.required, `<${tag}> objectName stays required`).toBe(true);
207+
expect(objectName.deprecated, `<${tag}> objectName is not deprecated`).toBeUndefined();
208+
}
209+
});
210+
});

packages/spec/src/ui/react-blocks.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ export interface ReactInteractionProp {
3535
kind: 'binding' | 'controlled' | 'callback';
3636
required?: boolean;
3737
description: string;
38+
/**
39+
* Deprecate-first retirement of a react-tier spelling (#11284, maintainer
40+
* ruling 2026-08-23): the react tier converges on the metadata-tier
41+
* vocabulary. A deprecated prop stays published and accepted for the whole
42+
* deprecation window — removal is a later card, never a side effect here.
43+
*
44+
* `replacedBy` names the canonical prop ON THE SAME BLOCK (a spec-schema
45+
* prop surfaced via `dataProps`, or another overlay prop); `note` is the
46+
* authoring guidance the lint quotes verbatim in its deprecation warning.
47+
*
48+
* On a `required` prop the requirement is the BINDING, not the spelling:
49+
* `validate-react-page-props` treats the canonical `replacedBy` prop as
50+
* satisfying it, so the new spelling is accepted without the old one.
51+
*/
52+
deprecated?: { replacedBy: string; note: string };
3853
}
3954

4055
/**
@@ -235,12 +250,43 @@ export const REACT_BLOCKS: ReactBlockDef[] = [
235250
{
236251
tag: 'ListView',
237252
schemaType: 'list-view',
238-
summary: "Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema.",
253+
summary: "Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema. Bind the object with the metadata-tier data source — data={{ provider: 'object', object: '…' }} — and pick the visualization with `type`; `objectName` / `viewType` are the deprecated spellings of the same two bindings.",
239254
schema: ListViewSchema,
240-
dataProps: ['columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'],
255+
// #11284 (maintainer ruling 2026-08-23): the react tier converges on the
256+
// metadata-tier vocabulary, deprecate-first. `type` and `data` are the
257+
// canonical spellings (ListViewSchema's own props — objectui#2890 A6:
258+
// `objectName` → `data: { provider: 'object', object }`, `viewType` →
259+
// `type`); the two overlay aliases below stay published for the window.
260+
// `type` rides the generator's explicit-allow (the #3729 ObjectChart
261+
// precedent — the react-page wrapper parks an author `type` beside the
262+
// SDUI discriminator as `specType`, objectui#2880).
263+
dataProps: ['type', 'data', 'columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'],
241264
interactions: [
242-
OBJECT_NAME,
243-
{ name: 'viewType', type: "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'", kind: 'binding', description: 'Which visualization to render (default grid). How you get a kanban/calendar/gantt of the object.' },
265+
// #11284 deprecate-first: NOT the shared OBJECT_NAME — ListView's object
266+
// binding converges on the schema's `data` data source; this alias stays
267+
// required so the contract keeps saying "bind something" (the lint lets
268+
// the canonical `data` prop satisfy it).
269+
{
270+
name: 'objectName',
271+
type: 'string',
272+
kind: 'binding',
273+
required: true,
274+
deprecated: {
275+
replacedBy: 'data',
276+
note: "Write the metadata-tier data source instead: data={{ provider: 'object', object: '…' }} — the same spelling a metadata list view authors. objectName keeps working during the deprecation window.",
277+
},
278+
description: "[DEPRECATED → `data={{ provider: 'object', object }}`] The object this block binds to (server-connected). Converging on the metadata-tier spelling (#11284); this alias is removed after the deprecation window.",
279+
},
280+
{
281+
name: 'viewType',
282+
type: "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'",
283+
kind: 'binding',
284+
deprecated: {
285+
replacedBy: 'type',
286+
note: 'Write type="kanban" (ListViewSchema\'s own `type`, the metadata-tier view kind) instead. viewType keeps working during the deprecation window.',
287+
},
288+
description: '[DEPRECATED → `type`] Which visualization to render (default grid). Converging on the metadata-tier spelling (#11284): write `type`, the same key a metadata list view authors.',
289+
},
244290
{ name: 'filters', type: "FilterArray e.g. ['status','=','active']", kind: 'controlled', description: 'ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: [\"and\", […], […]]).' },
245291
{ name: 'navigation', type: "{ mode: 'page' | 'drawer' | 'modal' | 'split' | 'none' }", kind: 'binding', description: 'What a row click does. Use { mode: \"none\" } when you handle clicks via onRowClick.' },
246292
{ name: 'onRowClick', type: '(record) => void', kind: 'callback', description: "Called with the clicked row's record — the hook for master/detail." },

0 commit comments

Comments
 (0)