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
6 changes: 6 additions & 0 deletions .changeset/react-tier-vocab-converge.md
Original file line numberDiff line numberDiff line change
@@ -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). `<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.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand Down
167 changes: 133 additions & 34 deletions packages/lint/src/validate-react-page-props.test.ts

Large diffs are not rendered by default.

94 changes: 82 additions & 12 deletions packages/lint/src/validate-react-page-props.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,13 +122,29 @@ const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) :
interface BlockSpec {
requiredBindings: string[];
knownProps: Set<string>;
/**
* [#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<string, { replacedBy: string; note: string }>;
}
const BLOCKS: Map<string, BlockSpec> = 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!]),
),
},
]),
);
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -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 `<ObjectChart>`
* the `data` prop is a static ROW ARRAY, and `<ObjectForm>`'s object binding
* is not converged by this step.
*/
function boundObjectName(tag: string, values: ReadonlyMap<string, unknown>): 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<string, unknown>,
Expand All@@ -855,7 +904,7 @@ function checkBlockFieldProps(
// one, so it must hand over the same index rather than answer differently.
unprovisionedAnchors?: ReadonlyMap<string, ReadonlySet<string>>,
): ReactPropFinding[] {
const objectName = strOf(values.get('objectName'));
const objectName = boundObjectName(tag, values);
const out: PageFieldFinding[] = [];

const spec = REACT_FIELD_SPECS[tag];
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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`,
Expand Down
21 changes: 19 additions & 2 deletions packages/spec/scripts/build-react-blocks-contract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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;
Expand Down
79 changes: 79 additions & 0 deletions packages/spec/src/ui/react-blocks.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
});
});
54 changes: 50 additions & 4 deletions packages/spec/src/ui/react-blocks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 };
}

/**
Expand DownExpand Up@@ -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." },
Expand Down
Loading
Loading