From 90e7072e0009733da2775514b0cf1231c64305b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:38:51 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .changeset/react-tier-vocab-converge.md | 6 + .../src/validate-react-page-props.test.ts | 167 ++++++++++++++---- .../lint/src/validate-react-page-props.ts | 94 ++++++++-- .../scripts/build-react-blocks-contract.ts | 21 ++- packages/spec/src/ui/react-blocks.test.ts | 79 +++++++++ packages/spec/src/ui/react-blocks.ts | 54 +++++- .../contracts/react-blocks.contract.json | 28 ++- .../objectstack-ui/references/react-blocks.md | 8 +- 8 files changed, 399 insertions(+), 58 deletions(-) create mode 100644 .changeset/react-tier-vocab-converge.md diff --git a/.changeset/react-tier-vocab-converge.md b/.changeset/react-tier-vocab-converge.md new file mode 100644 index 0000000000..7ffe742257 --- /dev/null +++ b/.changeset/react-tier-vocab-converge.md @@ -0,0 +1,6 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'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 ``'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. `` / `` `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. diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index abb306b955..9f115bddda 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -9,6 +9,7 @@ import { REACT_CHART_DRILLDOWN_INVALID, REACT_BLOCK_NEEDS_RECORD_CONTEXT, REACT_PAGE_SOURCE_UNPARSEABLE, + REACT_PROP_DEPRECATED, type ReactPropFinding as PropFinding, } from './validate-react-page-props.js'; // [#10653] The syntax gate whose cover the deleted `catch` comment credited, @@ -59,11 +60,109 @@ describe('validateReactPageProps (ADR-0081 Phase 2)', () => { }); it('does NOT false-flag a valid non-contract prop (no near match)', () => { - const f = validateReactPageProps(page('function Page(){ return ; }')); + const f = validateReactPageProps(page(`function Page(){ return ; }`)); expect(f).toEqual([]); }); }); +// ───────────────────────────────────────────────────────────────────────── +// #11284 — the react tier converges on the metadata-tier vocabulary, +// deprecate-first (maintainer ruling 2026-08-23). During the window BOTH +// spellings bind; the deprecated one warns on every use, and the canonical +// one satisfies the binding requirement the alias used to carry alone. +// ───────────────────────────────────────────────────────────────────────── + +describe('validateReactPageProps — deprecate-first vocabulary window (#11284)', () => { + it('accepts the canonical metadata-tier spelling with no findings', () => { + const f = validateReactPageProps( + page(`function Page(){ return ; }`), + ); + expect(f).toEqual([]); + }); + + it('accepts the deprecated objectName, with a warning naming the canonical prop', () => { + const f = validateReactPageProps(page('function Page(){ return ; }')); + expect(f).toHaveLength(1); + expect(f[0].severity).toBe('warning'); + expect(f[0].rule).toBe(REACT_PROP_DEPRECATED); + expect(f[0].message).toContain('"objectName"'); + expect(f[0].message).toContain('"data"'); + expect(f[0].hint).toContain("data={{ provider: 'object', object: '…' }}"); + }); + + it('warns on viewType, pointing at the metadata-tier `type`', () => { + const f = validateReactPageProps( + page(`function Page(){ return ; }`), + ); + expect(f).toHaveLength(1); + expect(f[0].rule).toBe(REACT_PROP_DEPRECATED); + expect(f[0].severity).toBe('warning'); + expect(f[0].message).toContain('"viewType"'); + expect(f[0].message).toContain('"type"'); + }); + + it('still errors when NEITHER spelling binds, naming canonical first', () => { + const f = validateReactPageProps(page(`function Page(){ return ; }`)); + expect(f.some((x) => x.rule === 'react-prop-missing-required')).toBe(true); + const miss = f.find((x) => x.rule === 'react-prop-missing-required')!; + expect(miss.message).toContain('data={…} (canonical)'); + expect(miss.message).toContain('objectName={…} (deprecated)'); + }); + + it('resolves field props against the object bound via canonical `data`', () => { + // The same stale-entry finding the deprecated spelling produces — the + // binding moved, the resolution must move with it. + const f = validateReactPageProps({ + objects: [{ name: 'crm_account', fields: { name: { type: 'text' } } }], + pages: [{ + name: 'p', kind: 'react', + source: `function Page(){ return ; }`, + }], + }); + const unknown = f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN); + expect(unknown).toHaveLength(1); + expect(unknown[0].message).toContain('crm_account'); + }); + + it('canonical wins when both spellings are present (the objectui fold direction), and the alias still warns', () => { + const f = validateReactPageProps({ + objects: [ + { name: 'real_obj', fields: { name: { type: 'text' } } }, + { name: 'stale_obj', fields: { other: { type: 'text' } } }, + ], + pages: [{ + name: 'p', kind: 'react', + source: `function Page(){ return ; }`, + }], + }); + // `name` resolves on real_obj (canonical) and NOT on stale_obj — no + // field finding means the canonical binding won. + expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toEqual([]); + expect(f.filter((x) => x.rule === REACT_PROP_DEPRECATED)).toHaveLength(1); + }); + + it('a deprecated prop behind a spread still warns — the spelling is literally written', () => { + const f = validateReactPageProps( + page('function Page(){ const p = {}; return ; }'), + ); + expect(f.filter((x) => x.rule === REACT_PROP_DEPRECATED)).toHaveLength(1); + }); + + it('a value-provider data source also satisfies the binding (static rows need no object)', () => { + const f = validateReactPageProps( + page(`function Page(){ return ; }`), + ); + expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); + }); + + it('ObjectForm and ObjectChart objectName stay undeprecated — not converged by this step', () => { + const f = validateReactPageProps( + page(`function Page(){ return <>; }`), + ); + expect(f.filter((x) => x.rule === REACT_PROP_DEPRECATED)).toEqual([]); + }); +}); + // ───────────────────────────────────────────────────────────────────────── // binding integrity (#3701) // ───────────────────────────────────────────────────────────────────────── @@ -292,14 +391,14 @@ const list = (attrs: string) => `function Page(){ return ; describe('validateReactPageProps — searchableFields (#4329)', () => { it('passes a declaration whose every entry resolves', () => { const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['name', 'billing_email']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['name', 'billing_email']}`)), ); expect(f).toEqual([]); }); it('flags a stale entry, gating, under the metadata rule id', () => { const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['name', 'email']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['name', 'email']}`)), ); expect(f).toHaveLength(1); @@ -317,14 +416,14 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('suggests the real field when the stale name is close to one', () => { const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['biling_email']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['biling_email']}`)), ); expect(f[0].message).toContain('Did you mean "billing_email"?'); }); it('accepts registry-injected system columns absent from authored fields', () => { const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['name', 'created_at']}`)), ); expect(f).toEqual([]); }); @@ -335,7 +434,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => // whole toolbar search, for every role. Same judgment as the metadata // list-view surface, by the shared core. const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['name', 'owner_id']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['name', 'owner_id']}`)), ); expect(f).toHaveLength(1); expect(f[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); @@ -359,7 +458,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => fields: { name: { type: 'text' } }, }; const f = validateReactPageProps( - listPage(list(`objectName="ext_account" searchableFields={['name', 'owner_id']}`), [external]), + listPage(list(`data={{ provider: 'object', object: 'ext_account' }} searchableFields={['name', 'owner_id']}`), [external]), ); expect(f).toHaveLength(1); @@ -372,7 +471,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('flags a dotted path — search cannot resolve the traversal', () => { const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['owner_id.name']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['owner_id.name']}`)), ); expect(f).toHaveLength(1); expect(f[0].hint).toContain("scans this object's own columns"); @@ -380,14 +479,14 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('skips an object this stack does not define', () => { const f = validateReactPageProps( - listPage(list(`objectName="pkg_contract" searchableFields={['no_such_field']}`)), + listPage(list(`data={{ provider: 'object', object: 'pkg_contract' }} searchableFields={['no_such_field']}`)), ); expect(f).toEqual([]); }); it('skips an object with no authored field map (external / introspected)', () => { const f = validateReactPageProps( - listPage(list(`objectName="external_invoice" searchableFields={['doc_no']}`), [ + listPage(list(`data={{ provider: 'object', object: 'external_invoice' }} searchableFields={['doc_no']}`), [ { name: 'external_invoice', external: { datasource: 'erp' } }, ]), ); @@ -397,7 +496,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('skips a value built from a variable (not knowable at build time)', () => { const f = validateReactPageProps( listPage( - 'function Page(){ const sf = ["nope"]; return ; }', + `function Page(){ const sf = ["nope"]; return ; }`, ), ); expect(f).toEqual([]); @@ -406,7 +505,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('skips everything behind a spread (props may come from it)', () => { const f = validateReactPageProps( listPage( - 'function Page(){ const p = {}; return ; }', + `function Page(){ const p = {}; return ; }`, ), ); expect(f).toEqual([]); @@ -443,7 +542,7 @@ describe('validateReactPageProps — field props (#4340)', () => { propsPage( jsx( 'ListView', - `objectName="crm_account" columns={['name']} fields={['billing_email']} ` + + `data={{ provider: 'object', object: 'crm_account' }} columns={['name']} fields={['billing_email']} ` + `sort={[{ field: 'name', order: 'asc' }]} grouping={{ fields: [{ field: 'name' }] }} ` + `userFilters={{ element: 'dropdown', fields: [{ field: 'billing_email' }] }}`, ), @@ -454,7 +553,7 @@ describe('validateReactPageProps — field props (#4340)', () => { it('flags a stale `columns` entry, advisory, under the metadata rule id', () => { const f = unknownFields( - validateReactPageProps(propsPage(jsx('ListView', `objectName="crm_account" columns={['name', 'revenue']}`))), + validateReactPageProps(propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} columns={['name', 'revenue']}`))), ); expect(f).toHaveLength(1); expect(f[0].severity).toBe('warning'); @@ -466,7 +565,7 @@ describe('validateReactPageProps — field props (#4340)', () => { it('flags the React overlay `fields` prop too — either spelling names columns', () => { const f = unknownFields( - validateReactPageProps(propsPage(jsx('ListView', `objectName="crm_account" fields={['nope']}`))), + validateReactPageProps(propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} fields={['nope']}`))), ); expect(f).toHaveLength(1); expect(f[0].path).toBe('pages[0].source › fields[0]'); @@ -475,7 +574,7 @@ describe('validateReactPageProps — field props (#4340)', () => { it('reads a `{field}` column record, not just a bare name', () => { const f = unknownFields( validateReactPageProps( - propsPage(jsx('ListView', `objectName="crm_account" columns={[{ field: 'nope', width: 120 }]}`)), + propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} columns={[{ field: 'nope', width: 120 }]}`)), ), ); expect(f).toHaveLength(1); @@ -484,11 +583,11 @@ describe('validateReactPageProps — field props (#4340)', () => { it('reads the LEGACY bare-string sort by its head, not the whole string', () => { const ok = unknownFields( - validateReactPageProps(propsPage(jsx('ListView', `objectName="crm_account" sort="name desc"`))), + validateReactPageProps(propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} sort="name desc"`))), ); expect(ok).toEqual([]); const bad = unknownFields( - validateReactPageProps(propsPage(jsx('ListView', `objectName="crm_account" sort="revenue desc"`))), + validateReactPageProps(propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} sort="revenue desc"`))), ); expect(bad).toHaveLength(1); expect(bad[0].message).toContain('"revenue"'); @@ -501,7 +600,7 @@ describe('validateReactPageProps — field props (#4340)', () => { propsPage( jsx( 'ListView', - `objectName="crm_account" userFilters={{ fields: [{ field: 'nope' }] }} grouping={{ fields: [{ field: 'also_nope' }] }}`, + `data={{ provider: 'object', object: 'crm_account' }} userFilters={{ fields: [{ field: 'nope' }] }} grouping={{ fields: [{ field: 'also_nope' }] }}`, ), ), ), @@ -514,7 +613,7 @@ describe('validateReactPageProps — field props (#4340)', () => { it('covers the legacy `filterableFields` shorthand', () => { const f = unknownFields( - validateReactPageProps(propsPage(jsx('ListView', `objectName="crm_account" filterableFields={['nope']}`))), + validateReactPageProps(propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} filterableFields={['nope']}`))), ); expect(f).toHaveLength(1); }); @@ -527,7 +626,7 @@ describe('validateReactPageProps — filter POSITIONS gate (#4340)', () => { const f = unknownFields( validateReactPageProps( propsPage( - 'function Page(){ const stage = "x"; return { it('passes a field position that resolves', () => { const f = validateReactPageProps( - propsPage(jsx('ListView', `objectName="crm_account" filters={['billing_email', '=', 'a@b.c']}`)), + propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} filters={['billing_email', '=', 'a@b.c']}`)), ); expect(f).toEqual([]); }); @@ -555,7 +654,7 @@ describe('validateReactPageProps — filter POSITIONS gate (#4340)', () => { const f = unknownFields( validateReactPageProps( propsPage( - jsx('ListView', `objectName="crm_account" filters={['and', ['name', '=', 'x'], ['nope', '>', 1]]}`), + jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} filters={['and', ['name', '=', 'x'], ['nope', '>', 1]]}`), ), ), ); @@ -564,7 +663,7 @@ describe('validateReactPageProps — filter POSITIONS gate (#4340)', () => { const flat = unknownFields( validateReactPageProps( - propsPage(jsx('ListView', `objectName="crm_account" filters={[['nope', '=', 1]]}`)), + propsPage(jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} filters={[['nope', '=', 1]]}`)), ), ); expect(flat).toHaveLength(1); @@ -572,14 +671,14 @@ describe('validateReactPageProps — filter POSITIONS gate (#4340)', () => { it('says nothing when the field POSITION itself is not static', () => { const f = validateReactPageProps( - propsPage('function Page(){ const c = "x"; return escape hatch (#4340)', () => { describe('validateReactPageProps — field-prop false-positive guards (#4340)', () => { it('skips an object this stack does not define', () => { const f = validateReactPageProps( - propsPage(jsx('ListView', `objectName="pkg_thing" columns={['nope']} filters={['nope', '=', 1]}`)), + propsPage(jsx('ListView', `data={{ provider: 'object', object: 'pkg_thing' }} columns={['nope']} filters={['nope', '=', 1]}`)), ); expect(f).toEqual([]); }); - it('skips a block with no static objectName', () => { + it('skips a block with no static binding (canonical `data` built from a variable)', () => { const f = validateReactPageProps( - propsPage('function Page(){ const o = "crm_account"; return ; }'), + propsPage(`function Page(){ const o = "crm_account"; return ; }`), ); expect(f).toEqual([]); }); it('skips a non-static value', () => { const f = validateReactPageProps( - propsPage('function Page(){ const c = ["nope"]; return ; }'), + propsPage(`function Page(){ const c = ["nope"]; return ; }`), ); expect(f).toEqual([]); }); @@ -770,7 +869,7 @@ describe('validateReactPageProps — field-prop false-positive guards (#4340)', it('skips everything behind a spread', () => { const f = validateReactPageProps( propsPage( - 'function Page(){ const p = {}; return ; }', + `function Page(){ const p = {}; return ; }`, ), ); expect(f).toEqual([]); @@ -779,7 +878,7 @@ describe('validateReactPageProps — field-prop false-positive guards (#4340)', it('accepts registry-injected system columns and relationship paths', () => { const f = validateReactPageProps( propsPage( - jsx('ListView', `objectName="crm_account" columns={['created_at', 'owner_id', 'owner_id.name']} filters={['created_at', '>', 1]}`), + jsx('ListView', `data={{ provider: 'object', object: 'crm_account' }} columns={['created_at', 'owner_id', 'owner_id.name']} filters={['created_at', '>', 1]}`), ), ); expect(f).toEqual([]); @@ -1244,7 +1343,7 @@ describe('validateReactPageProps — unprovisioned injected anchors (#8340)', () it('is silent on a declared field in the same filter position', () => { const f = validateReactPageProps( - extPage(`function Page(){ return ; }`), + extPage(`function Page(){ return ; }`), ); expect(f).toEqual([]); }); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 3f32f3b45f..4060ce7e0d 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -122,13 +122,29 @@ const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : interface BlockSpec { requiredBindings: string[]; knownProps: Set; + /** + * [#11284] Deprecated overlay spellings, read from the contract rather than + * restated: prop name → its canonical replacement + the authoring note the + * warning quotes. A `required` prop that is deprecated is a required + * BINDING, not a required spelling — the canonical `replacedBy` prop + * satisfies it (see the missing-required check below). + */ + deprecated: Map; } const BLOCKS: Map = new Map( - (REACT_BLOCKS as Array<{ tag: string; interactions: Array<{ name: string; required?: boolean }> }>).map((b) => [ + ( + REACT_BLOCKS as Array<{ + tag: string; + interactions: Array<{ name: string; required?: boolean; deprecated?: { replacedBy: string; note: string } }>; + }> + ).map((b) => [ b.tag, { requiredBindings: b.interactions.filter((i) => i.required).map((i) => i.name), knownProps: new Set(b.interactions.map((i) => i.name)), + deprecated: new Map( + b.interactions.filter((i) => i.deprecated).map((i) => [i.name, i.deprecated!]), + ), }, ]), ); @@ -280,6 +296,15 @@ function filterAttrValue(tsc: typeof ts, sf: ts.SourceFile, attr: ts.JsxAttribut */ export const REACT_PAGE_SOURCE_UNPARSEABLE = 'react-page-source-unparseable'; +/** + * [#11284] A prop written in a deprecated react-tier spelling (maintainer + * ruling 2026-08-23: the react tier converges on the metadata-tier + * vocabulary, deprecate-first). Warning, never error: the old spelling keeps + * working for the whole deprecation window — this is the loud half of + * "alias + loud deprecation", same shape as `approval-approver-type-deprecated`. + */ +export const REACT_PROP_DEPRECATED = 'react-prop-deprecated'; + export const REACT_CHART_FIELD_UNKNOWN = 'react-chart-field-unknown'; export const REACT_CHART_FIELD_UNPROVISIONED = 'react-chart-field-unprovisioned'; export const REACT_CHART_AGGREGATE_INVALID = 'react-chart-aggregate-invalid'; @@ -844,6 +869,30 @@ function reactFieldRefs( * (`page-field-unknown`): the same question, asked of the same component, with * the same fix. */ +/** + * The object a block is bound to, canonical spelling first. + * + * [#11284] ListView's canonical binding is the metadata-tier data source — + * `data={{ provider: 'object', object }}` — with `objectName` the deprecated + * alias for the deprecation window. Canonical wins when both are present, + * mirroring the one-directional fold objectui's `normalizeListViewSchema` + * applies at the component boundary. A non-static `data` (a variable, a + * spread-borne value) is `NOT_STATIC` here and falls back to `objectName` — + * unresolvable is not wrong (ADR-0072 D1). ListView only: on `` + * the `data` prop is a static ROW ARRAY, and ``'s object binding + * is not converged by this step. + */ +function boundObjectName(tag: string, values: ReadonlyMap): string | undefined { + if (tag === 'ListView') { + const data = values.get('data'); + if (isRec(data) && data.provider === 'object') { + const obj = strOf(data.object); + if (obj) return obj; + } + } + return strOf(values.get('objectName')); +} + function checkBlockFieldProps( tag: string, values: ReadonlyMap, @@ -855,7 +904,7 @@ function checkBlockFieldProps( // one, so it must hand over the same index rather than answer differently. unprovisionedAnchors?: ReadonlyMap>, ): ReactPropFinding[] { - const objectName = strOf(values.get('objectName')); + const objectName = boundObjectName(tag, values); const out: PageFieldFinding[] = []; const spec = REACT_FIELD_SPECS[tag]; @@ -1078,18 +1127,37 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { } if (!hasSpread) { for (const req of block.requiredBindings) { - if (!used.has(req)) { - findings.push({ - severity: 'error', - rule: 'react-prop-missing-required', - where, path, - message: `<${tag}> is missing the required prop "${req}".`, - hint: `Pass ${req}={…}. See the react-tier component contract.`, - }); - } + if (used.has(req)) continue; + // [#11284] A required prop that is DEPRECATED requires the + // binding, not the spelling: the canonical replacement satisfies + // it, so the new vocabulary is accepted without the old one. + const dep = block.deprecated.get(req); + if (dep && used.has(dep.replacedBy)) continue; + findings.push({ + severity: 'error', + rule: 'react-prop-missing-required', + where, path, + message: dep + ? `<${tag}> is missing its "${req}" binding — pass ${dep.replacedBy}={…} (canonical) or ${req}={…} (deprecated).` + : `<${tag}> is missing the required prop "${req}".`, + hint: dep ? dep.note : `Pass ${req}={…}. See the react-tier component contract.`, + }); } } for (const u of used) { + // [#11284] Deprecate-first: the old spelling keeps working, and + // every use says so — the contract's note names the canonical + // metadata-tier spelling to write instead. + const dep = block.deprecated.get(u); + if (dep) { + findings.push({ + severity: 'warning', + rule: REACT_PROP_DEPRECATED, + where, path, + message: `<${tag}> prop "${u}" is the deprecated spelling of the metadata-tier "${dep.replacedBy}" and is removed after the deprecation window (#11284).`, + hint: dep.note, + }); + } const near = nearestKnown(u, block.knownProps); if (near) { findings.push({ @@ -1119,7 +1187,9 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { findings.push( ...checkSearchableFieldList( values.get('searchableFields'), - strOf(values.get('objectName')), + // [#11284] canonical `data={{ provider: 'object', object }}` + // first, deprecated `objectName` as the window fallback. + boundObjectName(tag, values), searchTargets, where, `${path} › searchableFields`, diff --git a/packages/spec/scripts/build-react-blocks-contract.ts b/packages/spec/scripts/build-react-blocks-contract.ts index b1b758ca5c..653e5203f8 100644 --- a/packages/spec/scripts/build-react-blocks-contract.ts +++ b/packages/spec/scripts/build-react-blocks-contract.ts @@ -55,7 +55,15 @@ const clip = (s: unknown, n = 160): string => { return t.length > n ? t.slice(0, n - 1) + '…' : t; }; -interface Prop { name: string; type: string; kind: string; required: boolean; description: string } +interface Prop { + name: string; + type: string; + kind: string; + required: boolean; + description: string; + /** #11284 deprecate-first: canonical replacement + authoring note, passed through from the overlay. */ + deprecated?: { replacedBy: string; note: string }; +} function dataProps(schema: any, allow?: string[]): Prop[] { let js: any; @@ -94,7 +102,16 @@ function dataProps(schema: any, allow?: string[]): Prop[] { } function mergeProps(dataPs: Prop[], overlay: ReactInteractionProp[]): Prop[] { - const out: Prop[] = overlay.map((o) => ({ name: o.name, type: o.type, kind: o.kind, required: !!o.required, description: o.description })); + const out: Prop[] = overlay.map((o) => ({ + name: o.name, + type: o.type, + kind: o.kind, + required: !!o.required, + description: o.description, + // #11284 deprecate-first: machine consumers see the canonical replacement; + // the human-facing "[DEPRECATED → …]" marker rides the description text. + ...(o.deprecated ? { deprecated: o.deprecated } : {}), + })); const seen = new Set(out.map((p) => p.name)); for (const d of dataPs) if (!seen.has(d.name)) out.push(d); return out; diff --git a/packages/spec/src/ui/react-blocks.test.ts b/packages/spec/src/ui/react-blocks.test.ts index 9cdfe14c06..424bafe5e0 100644 --- a/packages/spec/src/ui/react-blocks.test.ts +++ b/packages/spec/src/ui/react-blocks.test.ts @@ -129,3 +129,82 @@ describe('REACT_BLOCKS — the record:* family is out (#4413)', () => { expect(tags).toContain('ObjectForm'); }); }); + +/** + * #11284 — the react tier converges on the metadata-tier vocabulary, + * deprecate-first (maintainer ruling 2026-08-23, recorded on-card). This step + * declares the canonical spellings and keeps the old ones as deprecated + * aliases; REMOVAL is a later card, so these pins hold the window open in both + * directions: the canonical props must be published, and the aliases must not + * quietly disappear before their card. + */ +describe('REACT_BLOCKS — deprecate-first vocabulary convergence (#11284)', () => { + it('every curated dataProps entry resolves to a real schema prop', () => { + // `build-react-blocks-contract`'s allow-list FILTERS the schema's props, so + // a curated name the schema does not declare is silently dropped from the + // published contract — the failure mode would be a canonical spelling that + // never actually ships. Pin the subset relation for every block. + for (const b of REACT_BLOCKS) { + if (!b.schema || !b.dataProps) continue; + const schemaProps = new Set(schemaPropNames(b.schema)); + const missing = b.dataProps.filter((p) => !schemaProps.has(p)); + expect(missing, `<${b.tag}> dataProps not on its spec schema`).toEqual([]); + } + }); + + it('a deprecated overlay prop names a real canonical prop on the same block, and says so in its description', () => { + for (const b of REACT_BLOCKS) { + const names = new Set([ + ...b.interactions.map((i) => i.name), + ...(b.schema ? schemaPropNames(b.schema) : []), + ]); + for (const i of b.interactions) { + if (!i.deprecated) continue; + expect( + names.has(i.deprecated.replacedBy), + `<${b.tag}> ${i.name} → "${i.deprecated.replacedBy}" names no prop on the block`, + ).toBe(true); + // The established textual convention (FormViewSchema.groups / + // drawerWidth): the marker travels in the published description. + expect( + i.description.startsWith('[DEPRECATED'), + `<${b.tag}> ${i.name} description must carry the [DEPRECATED → …] marker`, + ).toBe(true); + } + } + }); + + it('ListView: objectName→data and viewType→type, canonical props surfaced, aliases still published', () => { + const lv = REACT_BLOCKS.find((b) => b.tag === 'ListView')!; + const dep = Object.fromEntries( + lv.interactions.filter((i) => i.deprecated).map((i) => [i.name, i.deprecated!.replacedBy]), + ); + // The ruled mapping, exactly — objectui#2890 A6 (`objectName` → + // `data: { provider: 'object', object }`) and its sibling `viewType` → `type`. + expect(dep).toEqual({ objectName: 'data', viewType: 'type' }); + expect(lv.dataProps).toContain('type'); + expect(lv.dataProps).toContain('data'); + // Deprecate-first: the aliases stay for the whole window. + const names = lv.interactions.map((i) => i.name); + expect(names).toContain('objectName'); + expect(names).toContain('viewType'); + // The binding requirement survives the deprecation (the lint lets the + // canonical `data` prop satisfy it — see validate-react-page-props). + expect(lv.interactions.find((i) => i.name === 'objectName')!.required).toBe(true); + }); + + it('ObjectForm and ObjectChart objectName are NOT converged by this step', () => { + // ObjectForm: objectui#2890 Scope B says its spec counterpart is "not 1:1" + // and wants an audit before any swap. ObjectChart: chart.zod.ts's own + // guidance declares the `objectName` PROP the sanctioned react binding — + // the metadata tier binds charts through a dashboard `dataset`, a + // different mechanism, so there is no metadata-tier spelling to adopt. + // Extending the convergence to either is a new ruling, not a drive-by. + for (const tag of ['ObjectForm', 'ObjectChart']) { + const b = REACT_BLOCKS.find((x) => x.tag === tag)!; + const objectName = b.interactions.find((i) => i.name === 'objectName')!; + expect(objectName.required, `<${tag}> objectName stays required`).toBe(true); + expect(objectName.deprecated, `<${tag}> objectName is not deprecated`).toBeUndefined(); + } + }); +}); diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 94cf05fc27..817c8e3868 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -35,6 +35,21 @@ export interface ReactInteractionProp { kind: 'binding' | 'controlled' | 'callback'; required?: boolean; description: string; + /** + * Deprecate-first retirement of a react-tier spelling (#11284, maintainer + * ruling 2026-08-23): the react tier converges on the metadata-tier + * vocabulary. A deprecated prop stays published and accepted for the whole + * deprecation window — removal is a later card, never a side effect here. + * + * `replacedBy` names the canonical prop ON THE SAME BLOCK (a spec-schema + * prop surfaced via `dataProps`, or another overlay prop); `note` is the + * authoring guidance the lint quotes verbatim in its deprecation warning. + * + * On a `required` prop the requirement is the BINDING, not the spelling: + * `validate-react-page-props` treats the canonical `replacedBy` prop as + * satisfying it, so the new spelling is accepted without the old one. + */ + deprecated?: { replacedBy: string; note: string }; } /** @@ -235,12 +250,43 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ { tag: 'ListView', schemaType: 'list-view', - summary: "Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema.", + 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.", schema: ListViewSchema, - dataProps: ['columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'], + // #11284 (maintainer ruling 2026-08-23): the react tier converges on the + // metadata-tier vocabulary, deprecate-first. `type` and `data` are the + // canonical spellings (ListViewSchema's own props — objectui#2890 A6: + // `objectName` → `data: { provider: 'object', object }`, `viewType` → + // `type`); the two overlay aliases below stay published for the window. + // `type` rides the generator's explicit-allow (the #3729 ObjectChart + // precedent — the react-page wrapper parks an author `type` beside the + // SDUI discriminator as `specType`, objectui#2880). + dataProps: ['type', 'data', 'columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'], interactions: [ - OBJECT_NAME, - { 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.' }, + // #11284 deprecate-first: NOT the shared OBJECT_NAME — ListView's object + // binding converges on the schema's `data` data source; this alias stays + // required so the contract keeps saying "bind something" (the lint lets + // the canonical `data` prop satisfy it). + { + name: 'objectName', + type: 'string', + kind: 'binding', + required: true, + deprecated: { + replacedBy: 'data', + 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.", + }, + 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.", + }, + { + name: 'viewType', + type: "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'", + kind: 'binding', + deprecated: { + replacedBy: 'type', + note: 'Write type="kanban" (ListViewSchema\'s own `type`, the metadata-tier view kind) instead. viewType keeps working during the deprecation window.', + }, + 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.', + }, { 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\", […], […]]).' }, { 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.' }, { name: 'onRowClick', type: '(record) => void', kind: 'callback', description: "Called with the clicked row's record — the hook for master/detail." }, diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index ad1f08234d..91f23dfd1b 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -253,7 +253,7 @@ { "tag": "ListView", "schemaType": "list-view", - "summary": "Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema.", + "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.", "specSchema": true, "props": [ { @@ -261,14 +261,22 @@ "type": "string", "kind": "binding", "required": true, - "description": "The object this block binds to (server-connected)." + "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.", + "deprecated": { + "replacedBy": "data", + "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." + } }, { "name": "viewType", "type": "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'", "kind": "binding", "required": false, - "description": "Which visualization to render (default grid). How you get a kanban/calendar/gantt of the object." + "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.", + "deprecated": { + "replacedBy": "type", + "note": "Write type=\"kanban\" (ListViewSchema's own `type`, the metadata-tier view kind) instead. viewType keeps working during the deprecation window." + } }, { "name": "navigation", @@ -319,6 +327,20 @@ "required": true, "description": "Fields to display as columns" }, + { + "name": "type", + "type": "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'", + "kind": "data", + "required": false, + "description": "" + }, + { + "name": "data", + "type": "object", + "kind": "data", + "required": false, + "description": "Data source configuration (defaults to \"object\" provider)" + }, { "name": "sort", "type": "string | object[]", diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 5088bacaba..9c85fa32da 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -54,12 +54,12 @@ Server-connected create/edit/view form for one object. Config props come from th ## `` — `list-view` -Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema. +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. | prop | type | kind | required | description | |------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | ✓ | The object this block binds to (server-connected). | -| `viewType` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map'` | binding | | Which visualization to render (default grid). How you get a kanban/calendar/gantt of the object. | +| `objectName` | `string` | binding | ✓ | [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. | +| `viewType` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map'` | binding | | [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. | | `navigation` | `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none' }` | binding | | What a row click does. Use { mode: "none" } when you handle clicks via onRowClick. | | `fields` | `string[]` | binding | | Limit/order the columns shown (defaults to the object list fields). | | `options` | `Record` | binding | | View-type-specific options bag (kanban/calendar/gantt extras); prefer the typed spec props where they exist. | @@ -67,6 +67,8 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'edit') => void` | callback | | Called for page-level navigation. | | `columns` | `string[] \| object[]` | data | ✓ | Fields to display as columns | +| `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'` | data | | | +| `data` | `object` | data | | Data source configuration (defaults to "object" provider) | | `sort` | `string \| object[]` | data | | | | `searchableFields` | `string[]` | data | | Fields enabled for search | | `userFilters` | `object` | data | | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | From b3716338ef8ed9faff3443a125618856314076a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 12:15:16 +0000 Subject: [PATCH 2/2] fix(lint): export REACT_PROP_DEPRECATED from the barrel (rule-id-barrel-exports gate) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- packages/lint/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 00a777fc02..12592c9857 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -149,6 +149,7 @@ export { REACT_CHART_DRILLDOWN_INVALID, REACT_BLOCK_NEEDS_RECORD_CONTEXT, REACT_PAGE_SOURCE_UNPARSEABLE, + REACT_PROP_DEPRECATED, } from './validate-react-page-props.js'; export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';