diff --git a/.changeset/react-list-view-binding-aliases-retired.md b/.changeset/react-list-view-binding-aliases-retired.md new file mode 100644 index 0000000000..a3937ac4f0 --- /dev/null +++ b/.changeset/react-list-view-binding-aliases-retired.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +"@objectstack/example-showcase": patch +--- + +feat(spec)!: `` / `` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791) + + + +**BREAKING** — an accept-set narrowing on a published contract. The `REACT_BLOCKS` +ListView entry no longer publishes the `objectName` and `viewType` overlay props that +#11284 had deprecated in favour of ListViewSchema's own `data` / `type`: the generated +contract (`skills/objectstack-ui/references/react-blocks.md`) drops both rows, and +`@objectstack/lint`'s `validate-react-page-props` now REFUSES either spelling on a +`kind:'react'` page with a new `react-prop-retired` error that carries the fix, where it +used to warn and accept. Shipped as `minor` under the repo's launch-window convention for +breaking changes; the hand-migration prescription is registered under protocol major 18 +(`ui-react-list-view-binding-aliases-retired`). Maintainer ruling on #14791 (2026-09-07, +director seat summon #17, decision batch #1, option B — retire now, no deprecation window, +「同意」). + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `` | `` | +| `` | `` | +| `` with no binding at all | add `data={{ provider: 'object', object: '…' }}` — it is the required binding on a react page | + +One-line fix: on every `` in react page source replace `objectName="X"` with +`data={{ provider: 'object', object: 'X' }}` and `viewType="K"` with `type="K"`, then re-run +`objectstack validate` — a leftover alias is reported as `react-prop-retired` with this +same prescription, and a list with no data source as `react-prop-missing-required`. + +## Why now, and why no window + +The contract deprecated both aliases (#11284) while objectui's ListView still read only +`objectName`, so a page written the canonical way validated green and rendered an empty +list. That consumer half has landed and ships in the console this repo pins +(`normalizeListViewSchema` at `a472b071` folds `data.provider === 'object'` onto the key +the renderer reads and takes the author's `type` for the view kind), so both spellings +render today — and the maintainer's standing rule for a spelling with zero external +authors is to retire it at once rather than keep two vocabularies alive. + +## What else moved + +- `REACT_RETIRED_OVERLAY_PROPS` is a new export of `@objectstack/spec/ui`: the tombstone + ledger (prop → replacement + one-line fix) the lint quotes, the react-tier twin of a + metadata schema's `retiredKey()`. +- `data` is restated on the ListView overlay as its **required** binding (ledgered in + `REACT_OVERLAY_SHADOWS`), so the generated contract marks it ✓ and a `` with no + data source is refused — the check the required `objectName` used to carry. +- `REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']` prescribes the canonical spelling. +- The showcase pages (`crm-workbench`, `renewals-pipeline`, `task-desk`), the published + `objectstack-ui` skill and the react-pages / validating-metadata guides write the + canonical spelling; `@objectstack/lint` exports `REACT_PROP_RETIRED`. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 31b29ed8c2..c0d9a0dbce 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -308,8 +308,8 @@ name has the same failure: the block skips the name and renders one column, one filter chip, or one form field short. ```jsx - -// ↑ not a crm_account field → warning + +// ↑ not a crm_account field → warning ``` Checked on every injected block: ``'s @@ -336,9 +336,9 @@ They are withdrawn from the react tier, and using one is an **error** (`react-block-needs-record-context`) — by tag, and through `` alike. On a react page the parent record is ordinary React state, so bind it with a block that reads its own props: `', '=', parentId]}>` for a related list, -`` for a field panel. To use the family -itself, author the page as `type:'record'`. +data={{ provider: 'object', object: '' }} filters={['', '=', +parentId]}>` for a related list, `` for a +field panel. To use the family itself, author the page as `type:'record'`. On a **record page**, where these blocks do work, §5 checks their field-bearing props, and `` is the **related (child)** object @@ -349,8 +349,8 @@ there is the mistake that check was extended to catch. A **filter position** is the exception that gates: ```jsx - + ``` Function props are passed through as real callbacks — that is how one block drives another: ```jsx - setSelected(record.id)} /> + setSelected(record.id)} /> ``` One collision is worth knowing. `type` is the SDUI envelope's component discriminator @@ -155,7 +155,7 @@ Any registered component, including ones outside the curated contract: ``` A kanban, calendar, gantt, timeline or map of an object is also reachable without the -escape hatch — `` selects the visualization directly. +escape hatch — `` selects the visualization directly. `Block` is **not** a way back to the `record:*` family; see [below](#record-blocks-not-in-react). @@ -358,8 +358,9 @@ by tag and through `` alike: The error names the replacement for the block you reached for, so read the hint rather than a table here. In general: on a react page the parent record is React state, so bind it with a block that reads its **own** props — `` -for a field panel, `', '=', parentId]}>` -for a related list — or read the record with `useAdapter().findOne` and lay it out in JSX. +for a field panel, `' }} +filters={['', '=', parentId]}>` for a related list — or read the record with +`useAdapter().findOne` and lay it out in JSX. To use the family itself, author the page as `type:'record'` instead. ## How you check your work @@ -434,7 +435,7 @@ function Page() {
{ setSel(record.id); setEditing(false); }} @@ -457,7 +458,7 @@ function Page() { yAxis={[{ field: 'total' }]} title="Invoice value by status" /> - + diff --git a/examples/app-showcase/src/ui/pages/crm-workbench.page.ts b/examples/app-showcase/src/ui/pages/crm-workbench.page.ts index f005c79694..b0513630af 100644 --- a/examples/app-showcase/src/ui/pages/crm-workbench.page.ts +++ b/examples/app-showcase/src/ui/pages/crm-workbench.page.ts @@ -85,7 +85,7 @@ function Page() {
-
diff --git a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts index 6c7686ce6f..8fcaabbecf 100644 --- a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts +++ b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts @@ -133,8 +133,8 @@ function Page() { ))}

Invoices

- + {editing ? ( - setEditId(r.id)} /> diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index a836e8ace5..c0777e4113 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -196,6 +196,7 @@ export { REACT_BLOCK_NEEDS_RECORD_CONTEXT, REACT_PAGE_SOURCE_UNPARSEABLE, REACT_PROP_DEPRECATED, + REACT_PROP_RETIRED, } from './validate-react-page-props.js'; export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js'; diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 4be884418f..8ba80f2193 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -10,6 +10,7 @@ import { REACT_BLOCK_NEEDS_RECORD_CONTEXT, REACT_PAGE_SOURCE_UNPARSEABLE, REACT_PROP_DEPRECATED, + REACT_PROP_RETIRED, type ReactPropFinding as PropFinding, } from './validate-react-page-props.js'; // [#10653] The syntax gate whose cover the deleted `catch` comment credited, @@ -52,7 +53,7 @@ describe('validateReactPageProps (ADR-0081 Phase 2)', () => { }); it('flags ListView onRowClik typo', () => { - const f = validateReactPageProps(page('function Page(){ return {}} />; }')); + const f = validateReactPageProps(page('function Page(){ return {}} />; }')); expect(f.some((x) => x.rule === 'react-prop-typo' && /onRowClick/.test(x.message))).toBe(true); }); @@ -67,7 +68,7 @@ describe('validateReactPageProps (ADR-0081 Phase 2)', () => { }); it('does NOT false-flag a valid non-contract prop (no near match)', () => { - const f = pastDeprecation(validateReactPageProps(page(`function Page(){ return ; }`))); + const f = pastDeprecation(validateReactPageProps(page(`function Page(){ return ; }`))); expect(f).toEqual([]); }); }); @@ -79,93 +80,81 @@ describe('validateReactPageProps (ADR-0081 Phase 2)', () => { // one satisfies the binding requirement the alias used to carry alone. // ───────────────────────────────────────────────────────────────────────── -describe('validateReactPageProps — deprecate-first vocabulary window (#11284 → #14791)', () => { - // [#14791] Step 1 of the maintainer's ruling (2026-09-03) INVERTS the four - // cases below. They pinned that the canonical metadata-tier spelling binds a - // ListView; objectui's renderer does not read it, so a page written that way - // validated green and rendered an empty list. Kept and turned round rather - // than deleted — the vocabulary window is still open, only its blessing is - // withdrawn until the consumer fold lands. - it('REFUSES a page bound only by the canonical data source, naming objectName', () => { - const f = validateReactPageProps( - page(`function Page(){ return ; }`), - ); - const miss = f.filter((x) => x.rule === 'react-prop-missing-required'); - expect(miss).toHaveLength(1); - expect(miss[0].severity).toBe('error'); - // Sent to the spelling that renders, and NOT offered the canonical one: - // following that advice today is what produces the empty list. - expect(miss[0].message).toContain('objectName={…}'); - expect(miss[0].message).toContain('render an empty list'); - expect(miss[0].message).not.toContain('(canonical)'); - expect(miss[0].hint).toContain('objectName="…"'); +describe('validateReactPageProps — the ListView binding aliases are retired (#11284 → #14791)', () => { + // [#14791] Step 3 of the maintainer's ruling (2026-09-07, no deprecation + // window): `objectName` / `viewType` are gone from the ListView contract and + // `data={{ provider: 'object', object }}` / `type` are the only spellings. + // The consumer fold landed (objectui `normalizeListViewSchema`, console pin + // a472b071), so the canonical binding IS what the renderer reads: the four + // step-1 inversions turn back round, and the alias cases become refusals + // that carry the prescription. + it('passes a page bound by the canonical data source, and resolves fields through it', () => { + const f = validateReactPageProps({ + objects: [{ name: 'crm_account', fields: { name: { type: 'text' } } }], + pages: [{ + name: 'p', kind: 'react', + source: `function Page(){ return ; }`, + }], + }); + expect(f).toEqual([]); }); - it('passes a page bound with objectName — the spelling the renderer reads', () => { + it('REFUSES the retired objectName spelling by name, with the prescription — and only once', () => { const f = validateReactPageProps(page('function Page(){ return ; }')); - expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); expect(f).toHaveLength(1); - expect(f[0].severity).toBe('warning'); - expect(f[0].rule).toBe(REACT_PROP_DEPRECATED); + expect(f[0].rule).toBe(REACT_PROP_RETIRED); + expect(f[0].severity).toBe('error'); expect(f[0].message).toContain('"objectName"'); expect(f[0].message).toContain('"data"'); - // …and the warning no longer sends the author to the canonical spelling. - expect(f[0].message).toContain('the only one this block renders'); - expect(f[0].hint).toContain('Keep "objectName" for now'); + // The fix is spelled out, FROM → TO, in the finding itself. + expect(f[0].hint).toContain("data={{ provider: 'object', object: '…' }}"); + // The binding IS missing, but the retired-prop error already names the + // exact prop that carries it — no second finding for the same fix, no typo + // guess, and no deprecation warning: the window is closed. + expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); + expect(f.filter((x) => x.rule === 'react-prop-typo')).toEqual([]); + expect(f.filter((x) => x.rule === REACT_PROP_DEPRECATED)).toEqual([]); }); - it('warns on viewType, naming `type` without prescribing it', () => { + it('REFUSES the retired viewType spelling, naming `type`', () => { const f = validateReactPageProps( - page(`function Page(){ return ; }`), + page(`function Page(){ return ; }`), ); - const dep = f.filter((x) => x.rule === REACT_PROP_DEPRECATED && /viewType/.test(x.message)); - expect(dep).toHaveLength(1); - expect(dep[0].severity).toBe('warning'); - expect(dep[0].message).toContain('"viewType"'); - expect(dep[0].message).toContain('"type"'); - expect(dep[0].message).toContain('the only one this block renders'); + expect(f).toHaveLength(1); + expect(f[0].rule).toBe(REACT_PROP_RETIRED); + expect(f[0].severity).toBe('error'); + expect(f[0].message).toContain('"viewType"'); + expect(f[0].message).toContain('"type"'); + expect(f[0].hint).toContain('type="kanban"'); }); - it('still errors when nothing binds at all — naming objectName, not the canonical spelling', () => { + it('errors when nothing binds at all — `data` is the required binding on a react page', () => { const f = validateReactPageProps(page(`function Page(){ return ; }`)); - const miss = f.find((x) => x.rule === 'react-prop-missing-required'); - expect(miss).toBeDefined(); - expect(miss!.message).toContain('objectName={…}'); - expect(miss!.message).not.toContain('(canonical)'); - // No `data` was written, so the empty-list clause does not apply. - expect(miss!.message).not.toContain('render an empty list'); + expect(f).toHaveLength(1); + expect(f[0].rule).toBe('react-prop-missing-required'); + expect(f[0].severity).toBe('error'); + expect(f[0].message).toContain('"data"'); + expect(f[0].message).not.toContain('objectName'); + // The hint is the contract's own description of the binding, so the + // author gets the spelling rather than a pointer to go and find it. + expect(f[0].hint).toContain("data={{ provider: 'object', object: '…' }}"); }); - it('resolves field props against objectName, the binding the renderer reads', () => { + it('resolves field props against data.object — the object the page really queries', () => { const f = validateReactPageProps({ objects: [{ name: 'crm_account', fields: { name: { type: 'text' } } }], pages: [{ name: 'p', kind: 'react', - source: `function Page(){ return ; }`, + 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'); + expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); }); - it('a canonically-bound page resolves NO fields — the refusal is the only finding', () => { - // The reason the canonical-first branch was dropped rather than merely - // demoted: it resolved column refs against an object the page never - // queries, so a stale column was reported (or cleared) against the wrong - // object entirely. - const f = validateReactPageProps({ - objects: [{ name: 'crm_account', fields: { name: { type: 'text' } } }], - pages: [{ - name: 'p', kind: 'react', - source: `function Page(){ return ; }`, - }], - }); - expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toEqual([]); - expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toHaveLength(1); - }); - - it('the DEPRECATED spelling wins when both are present — it is the one the renderer reads', () => { + it('with both spellings present, the retired one is refused and fields resolve against the canonical one', () => { const f = validateReactPageProps({ objects: [ { name: 'real_obj', fields: { name: { type: 'text' } } }, @@ -176,48 +165,44 @@ describe('validateReactPageProps — deprecate-first vocabulary window (#11284 source: `function Page(){ return ; }`, }], }); - // `name` resolves on real_obj and NOT on stale_obj. objectui queries - // `schema.objectName`, so stale_obj is what this page really fetches and - // the unresolved column is a TRUE finding. The previous expectation had it - // the other way round, mirroring a fold that does not exist. - const unknown = f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN); - expect(unknown).toHaveLength(1); - expect(unknown[0].message).toContain('stale_obj'); - expect(f.filter((x) => x.rule === REACT_PROP_DEPRECATED)).toHaveLength(1); - // Both spellings present ⇒ the binding IS satisfied; no refusal. + // `name` resolves on real_obj: the fold makes `data.object` the object the + // renderer fetches, so stale_obj is never the one to check against. + expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toEqual([]); + const retired = f.filter((x) => x.rule === REACT_PROP_RETIRED); + expect(retired).toHaveLength(1); + expect(retired[0].message).toContain('"objectName"'); expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); }); - it('a deprecated prop behind a spread still warns — the spelling is literally written', () => { + it('a retired prop behind a spread is still refused — 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); + expect(f.filter((x) => x.rule === REACT_PROP_RETIRED)).toHaveLength(1); }); - it('a value-provider data source still satisfies the binding — static rows DO render', () => { - // The tightening stops at the OBJECT provider on purpose. objectui's fetch - // effect implements `provider: 'value'`, a plain-array shorthand and the - // renderer-owned `api` provider; only `provider: 'object'` falls through - // them all to the objectName-gated fetch and returns no rows. + it('a value-provider data source satisfies the binding — static rows DO render', () => { const f = validateReactPageProps( page(`function Page(){ return ; }`), ); expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); + expect(f.filter((x) => x.rule === REACT_PROP_RETIRED)).toEqual([]); }); - it('a non-static data source still satisfies — unresolvable is not wrong (ADR-0072 D1)', () => { + it('a non-static data source satisfies — unresolvable is not wrong (ADR-0072 D1)', () => { const f = validateReactPageProps( page('function Page(){ const d = { provider: "object", object: "a" }; return ; }'), ); expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); }); - it('ObjectForm and ObjectChart objectName stay undeprecated — not converged by this step', () => { + it('ObjectForm and ObjectChart objectName stay live — neither converged nor retired', () => { const f = validateReactPageProps( page(`function Page(){ return <>; }`), ); + expect(f.filter((x) => x.rule === REACT_PROP_RETIRED)).toEqual([]); expect(f.filter((x) => x.rule === REACT_PROP_DEPRECATED)).toEqual([]); + expect(f.filter((x) => x.rule === 'react-prop-missing-required')).toEqual([]); }); }); @@ -449,14 +434,14 @@ const list = (attrs: string) => `function Page(){ return ; describe('validateReactPageProps — searchableFields (#4329)', () => { it('passes a declaration whose every entry resolves', () => { const f = pastDeprecation(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 = pastDeprecation(validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['name', 'email']}`)), + listPage(list(`data={{ provider: 'object', object: 'crm_account' }} searchableFields={['name', 'email']}`)), )); expect(f).toHaveLength(1); @@ -474,14 +459,14 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('suggests the real field when the stale name is close to one', () => { const f = pastDeprecation(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 = pastDeprecation(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([]); }); @@ -492,7 +477,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 = pastDeprecation(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); @@ -516,7 +501,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => fields: { name: { type: 'text' } }, }; const f = pastDeprecation(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); @@ -529,7 +514,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('flags a dotted path — search cannot resolve the traversal', () => { const f = pastDeprecation(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"); @@ -537,14 +522,14 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('skips an object this stack does not define', () => { const f = pastDeprecation(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 = pastDeprecation(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' } }, ]), )); @@ -554,7 +539,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('skips a value built from a variable (not knowable at build time)', () => { const f = pastDeprecation(validateReactPageProps( listPage( - `function Page(){ const sf = ["nope"]; return ; }`, + `function Page(){ const sf = ["nope"]; return ; }`, ), )); expect(f).toEqual([]); @@ -563,7 +548,7 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('skips everything behind a spread (props may come from it)', () => { const f = pastDeprecation(validateReactPageProps( listPage( - `function Page(){ const p = {}; return ; }`, + `function Page(){ const p = {}; return ; }`, ), )); expect(f).toEqual([]); @@ -600,7 +585,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' }] }}`, ), @@ -611,7 +596,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'); @@ -623,7 +608,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]'); @@ -632,7 +617,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); @@ -641,11 +626,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"'); @@ -658,7 +643,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' }] }}`, ), ), ), @@ -671,7 +656,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); }); @@ -684,7 +669,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 = pastDeprecation(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([]); }); @@ -712,7 +697,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]]}`), ), ), ); @@ -721,7 +706,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); @@ -729,14 +714,14 @@ describe('validateReactPageProps — filter POSITIONS gate (#4340)', () => { it('says nothing when the field POSITION itself is not static', () => { const f = pastDeprecation(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 = pastDeprecation(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 binding (canonical `data` built from a variable)', () => { const f = pastDeprecation(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 = pastDeprecation(validateReactPageProps( - propsPage(`function Page(){ const c = ["nope"]; return ; }`), + propsPage(`function Page(){ const c = ["nope"]; return ; }`), )); expect(f).toEqual([]); }); @@ -927,7 +912,7 @@ describe('validateReactPageProps — field-prop false-positive guards (#4340)', it('skips everything behind a spread', () => { const f = pastDeprecation(validateReactPageProps( propsPage( - `function Page(){ const p = {}; return ; }`, + `function Page(){ const p = {}; return ; }`, ), )); expect(f).toEqual([]); @@ -936,7 +921,7 @@ describe('validateReactPageProps — field-prop false-positive guards (#4340)', it('accepts registry-injected system columns and relationship paths', () => { const f = pastDeprecation(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([]); @@ -1389,7 +1374,7 @@ describe('validateReactPageProps — unprovisioned injected anchors (#8340)', () // `` is the react surface's filter position: the ref goes // to a QUERY, so the message names the silent-zero degradation. const f = validateReactPageProps( - extPage(`function Page(){ return ; }`), + extPage(`function Page(){ return ; }`), ); expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toHaveLength(0); const warned = f.filter((x) => x.rule === PAGE_FIELD_UNPROVISIONED); @@ -1401,7 +1386,7 @@ describe('validateReactPageProps — unprovisioned injected anchors (#8340)', () it('is silent on a declared field in the same filter position', () => { const f = pastDeprecation(validateReactPageProps( - extPage(`function Page(){ return ; }`), + extPage(`function Page(){ return ; }`), )); expect(f).toEqual([]); }); @@ -1440,7 +1425,7 @@ describe('validateReactPageProps — unprovisioned injected anchors (#8340)', () // `` is a DISPLAY binding, not a query: the consequence // clause is the blank-column one, not the constant-false one. const f = validateReactPageProps( - extPage(`function Page(){ return ; }`), + extPage(`function Page(){ return ; }`), ); expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toHaveLength(0); const warned = f.filter((x) => x.rule === PAGE_FIELD_UNPROVISIONED); @@ -1584,7 +1569,7 @@ describe('an unparseable react source is reported, not scored clean (#10653)', ( 'const Page = (): JSX.Element => ;\nexport default Page;', 'const id = (x: T): T => x;\nfunction Page(){ return ; }', 'interface Props { a: number }\nexport default function Page(p: Props){ return ; }', - 'function Page(){ return <>; }', + 'function Page(){ return <>; }', 'function Page(){ return ; }', ]; for (const source of parseable) { diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index edaa9f98cf..c2955e5de5 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -37,6 +37,7 @@ import { createRequire } from 'node:module'; import type ts from 'typescript'; import { REACT_BLOCKS, + REACT_RETIRED_OVERLAY_PROPS, RECORD_CONTEXT_BLOCK_TAGS, REACT_RECORD_BLOCK_ALTERNATIVES, ChartAggregateSchema, @@ -127,17 +128,25 @@ interface BlockSpec { * 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). [#14791] The one - * exception is ListView's object binding, whose canonical replacement no - * renderer reads yet: see `objectProviderBindsNothing`. + * satisfies it (see the missing-required check below). */ deprecated: Map; + /** + * [#14791] Overlay spellings RETIRED from the block, read from the contract's + * `REACT_RETIRED_OVERLAY_PROPS`: prop name → the prop that carries the + * binding now + the one-line fix. Writing one is an error carrying that + * fix, never a typo guess and never silence — the renderer may still read + * the old key, which is exactly how a retired spelling would keep shipping. + */ + retired: Map; + /** The contract's own description per required binding — the hint when one is missing. */ + requiredDescriptions: Map; } const BLOCKS: Map = new Map( ( REACT_BLOCKS as Array<{ tag: string; - interactions: Array<{ name: string; required?: boolean; deprecated?: { replacedBy: string; note: string } }>; + interactions: Array<{ name: string; required?: boolean; description: string; deprecated?: { replacedBy: string; note: string } }>; }> ).map((b) => [ b.tag, @@ -147,6 +156,10 @@ const BLOCKS: Map = new Map( deprecated: new Map( b.interactions.filter((i) => i.deprecated).map((i) => [i.name, i.deprecated!]), ), + retired: new Map(Object.entries(REACT_RETIRED_OVERLAY_PROPS[b.tag] ?? {})), + requiredDescriptions: new Map( + b.interactions.filter((i) => i.required).map((i) => [i.name, i.description]), + ), }, ]), ); @@ -307,6 +320,15 @@ export const REACT_PAGE_SOURCE_UNPARSEABLE = 'react-page-source-unparseable'; */ export const REACT_PROP_DEPRECATED = 'react-prop-deprecated'; +/** + * [#14791] A prop written in a react-tier spelling the contract has RETIRED + * (maintainer ruling 2026-09-07: `` / `viewType` go with + * no deprecation window). Error, never warning: the contract no longer + * publishes the spelling, and the finding carries the prescription — the + * react-tier twin of a metadata schema's `retiredKey()` tombstone. + */ +export const REACT_PROP_RETIRED = 'react-prop-retired'; + 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'; @@ -874,69 +896,20 @@ function reactFieldRefs( /** * The object a block is bound to. * - * `objectName` is the only spelling that binds one. ListView's canonical - * `data={{ provider: 'object', object }}` is deliberately NOT read here - * (#14791 step 1): no renderer folds it into `objectName`, so resolving field - * refs through it would check them against an object the page never queries — - * and, when an author writes both spellings, against the WRONG one, since the - * renderer reads `objectName`. The canonical read returns in step 3, once the - * consumer fold has landed. - * - * The comment this replaces claimed the canonical-first precedence mirrored a - * one-directional fold in objectui's `normalizeListViewSchema`. That fold does - * not exist: `normalize-list-view.ts` contains no `.object` read at all, and - * `ListView.tsx`'s only query is `dataSource.find(schema.objectName, …)`. - */ -function boundObjectName(values: ReadonlyMap): string | undefined { - return strOf(values.get('objectName')); -} - -/** - * The ListView deprecations whose canonical replacement NO renderer reads yet. - * - * Maintainer ruling 2026-09-03 (option B, step 1 of 3): the react tier keeps - * converging on the metadata-tier vocabulary, but until objectui folds - * `data={{ provider: 'object', object }}` into `objectName` and reads `type` - * for the view kind, both canonical spellings reach a renderer that ignores - * them. Measured on objectui `a27d153c`: `ListView.tsx` contains zero - * occurrences of `data.object`, `dataConfig.object`, `provider === 'object'` - * and `specType`, against 52 of `schema.objectName`. - * - * So the deprecation warning must not send an author to a spelling that - * renders nothing — for these two props it says the opposite of the usual - * deprecate-first advice. Step 3 deletes this set along with the aliases. + * `` binds through ListViewSchema's own data source — + * `data={{ provider: 'object', object }}`, the only spelling since #14791 + * retired the `objectName` alias. objectui's `normalizeListViewSchema` folds + * that source onto the key its renderer reads (console pin a472b071), so + * resolving field refs through it checks them against the object the page + * really queries. A non-object provider (`'value'` rows, the renderer-owned + * `'api'`) binds no object to resolve against, and a non-static `data` is + * unresolvable rather than wrong (ADR-0072 D1) — both return undefined and the + * field checks skip. Every other block binds by its `objectName` overlay prop. */ -const UNFOLDED_DEPRECATIONS: ReadonlyMap> = new Map([ - ['ListView', new Set(['objectName', 'viewType'])], -]); -const replacementUnread = (tag: string, prop: string): boolean => - UNFOLDED_DEPRECATIONS.get(tag)?.has(prop) ?? false; - -/** - * Does the author's canonical ListView binding bind nothing? - * - * The same ruling, its sharper half: a required prop that is deprecated is - * normally satisfied by its canonical replacement (the mechanism the contract - * publishes). For ListView's object binding that mechanism blesses a page - * which renders an EMPTY LIST with no diagnostic, so it stops applying here - * and `os validate` fails loudly instead. - * - * Scoped to the OBJECT provider on purpose. `ListView.tsx`'s fetch effect - * implements `provider: 'value'`, a plain-array shorthand and the - * renderer-owned `api` provider; only `provider: 'object'` falls through them - * all to the `objectName`-gated fetch, which clears the loading state and - * returns no rows. Refusing the providers that DO render would tighten past - * what renders — the opposite of this step's stated reason. A non-static - * `data` is unresolvable rather than wrong (ADR-0072 D1) and still satisfies. - */ -function objectProviderBindsNothing( - tag: string, - req: string, - values: ReadonlyMap, -): boolean { - if (tag !== 'ListView' || req !== 'objectName') return false; +function boundObjectName(tag: string, values: ReadonlyMap): string | undefined { + if (tag !== 'ListView') return strOf(values.get('objectName')); const data = values.get('data'); - return isRec(data) && data.provider === 'object'; + return isRec(data) && data.provider === 'object' ? strOf(data.object) : undefined; } function checkBlockFieldProps( @@ -950,7 +923,7 @@ function checkBlockFieldProps( // one, so it must hand over the same index rather than answer differently. unprovisionedAnchors?: ReadonlyMap>, ): ReactPropFinding[] { - const objectName = boundObjectName(values); + const objectName = boundObjectName(tag, values); const out: PageFieldFinding[] = []; const spec = REACT_FIELD_SPECS[tag]; @@ -1175,55 +1148,55 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { // [#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. - // [#14791] …EXCEPT where no renderer reads that replacement, in - // which case accepting it would bless an empty list. const dep = block.deprecated.get(req); - const bindsNothing = objectProviderBindsNothing(tag, req, values); - if (dep && used.has(dep.replacedBy) && !bindsNothing) continue; - const unread = dep !== undefined && replacementUnread(tag, req); + if (dep && used.has(dep.replacedBy)) continue; + // [#14791] A RETIRED spelling of this binding is on the element: + // the retired-prop error below already names the exact prop + // that carries it, so a second finding would only repeat the fix. + if ([...used].some((u) => block.retired.get(u)?.replacedBy === req)) continue; findings.push({ severity: 'error', rule: 'react-prop-missing-required', where, path, - // The message never offers the canonical spelling for a - // replacement no renderer reads — with or without a `data` - // prop present, `objectName` is the only answer that renders. - message: unread - ? `<${tag}> is missing its "${req}" binding — pass ${req}={…}.${ - bindsNothing - ? ` A data={{ provider: 'object', … }} source does not bind a <${tag}>: the renderer reads "${req}" only, so this page would render an empty list.` - : '' - }` - : dep - ? `<${tag}> is missing its "${req}" binding — pass ${dep.replacedBy}={…} (canonical) or ${req}={…} (deprecated).` - : `<${tag}> is missing the required prop "${req}".`, - hint: unread - ? `Write ${req}="…" — it is the spelling this block renders today. The metadata-tier data source binds it only once the renderer folds that source in.` - : dep ? dep.note : `Pass ${req}={…}. See the react-tier component contract.`, + message: dep + ? `<${tag}> is missing its "${req}" binding — pass ${dep.replacedBy}={…} (canonical) or ${req}={…} (deprecated).` + : `<${tag}> is missing the required prop "${req}".`, + // The contract's own description of the binding says how to + // write it — the author gets the spelling, not a pointer. + hint: dep + ? dep.note + : block.requiredDescriptions.get(req) ?? `Pass ${req}={…}. See the react-tier component contract.`, }); } } for (const u of used) { + // [#14791] A RETIRED spelling: the contract no longer publishes + // it, so this is an error carrying the prescription — never a typo + // guess, and never silence, since the renderer may still read the + // old key and would let the page ship on it. + const ret = block.retired.get(u); + if (ret) { + findings.push({ + severity: 'error', + rule: REACT_PROP_RETIRED, + where, path, + // (#14791 — the id stays here, out of the string authors read.) + message: `<${tag}> prop "${u}" is retired — the contract's only spelling is "${ret.replacedBy}".`, + hint: ret.note, + }); + continue; + } // [#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) { - // [#14791] The contract's own note tells the author to write the - // canonical spelling instead. For the two ListView aliases that - // is advice to render nothing, so the warning says the opposite - // until the consumer fold lands (step 2 of the ruling). - const unread = replacementUnread(tag, u); findings.push({ severity: 'warning', rule: REACT_PROP_DEPRECATED, where, path, - message: unread - ? `<${tag}> prop "${u}" is the deprecated spelling of the metadata-tier "${dep.replacedBy}", and is still the only one this block renders — keep writing it until the renderer reads "${dep.replacedBy}".` - : `<${tag}> prop "${u}" is the deprecated spelling of the metadata-tier "${dep.replacedBy}" and is removed after the deprecation window (#11284).`, - hint: unread - ? `Keep "${u}" for now: the contract's note points at "${dep.replacedBy}", but that spelling does not reach this renderer yet, so following it today renders nothing.` - : dep.note, + 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); @@ -1255,9 +1228,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { findings.push( ...checkSearchableFieldList( values.get('searchableFields'), - // [#14791] `objectName` only — the canonical data source does - // not reach this renderer, so it binds nothing to search. - boundObjectName(values), + boundObjectName(tag, values), searchTargets, where, `${path} › searchableFields`, diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a18527cee2..af61eab875 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -301,6 +301,7 @@ "REACT_BLOCKS (const)", "REACT_OVERLAY_SHADOWS (const)", "REACT_RECORD_BLOCK_ALTERNATIVES (const)", + "REACT_RETIRED_OVERLAY_PROPS (const)", "RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES (const)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 5448c21774..2a125ca3c7 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -295,6 +295,7 @@ "REACT_BLOCKS": "src/ui/react-blocks.ts#REACT_BLOCKS (const)", "REACT_OVERLAY_SHADOWS": "src/ui/react-blocks.ts#REACT_OVERLAY_SHADOWS (const)", "REACT_RECORD_BLOCK_ALTERNATIVES": "src/ui/react-blocks.ts#REACT_RECORD_BLOCK_ALTERNATIVES (const)", + "REACT_RETIRED_OVERLAY_PROPS": "src/ui/react-blocks.ts#REACT_RETIRED_OVERLAY_PROPS (const)", "RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)", diff --git a/packages/spec/react-declaration-parity.baseline.json b/packages/spec/react-declaration-parity.baseline.json index 636a4c7424..0c4691269c 100644 --- a/packages/spec/react-declaration-parity.baseline.json +++ b/packages/spec/react-declaration-parity.baseline.json @@ -4,7 +4,9 @@ "_note": "Hand-maintained record of WHY each accepted registry-only input is deliberately not declared in spec (maintainer ruling 2026-08-18 on #9392). `--update` rewrites this file and drops this block — re-add it (updated) whenever the accepted set changes; an entry with no reason here is an accept nobody ruled.", "ObjectForm.initialData": "Alias spelling of `initialValues` (the drawer/modal presentations read it first). Aliases are not promoted into spec, per the alias-convergence direction.", "ObjectForm.mobile": "Internal presentation override, not an authoring surface.", - "ObjectForm.navigateOnSuccess": "Explicitly parked pending the 'action success navigation' family ruling (#9474/#9566, objectui#5034) — declaring it now risks freezing a spelling that family is shaping; revisit when `onSuccess` lands. The family landed on 2026-08-18 (PR #9601); the revisit is tracked on #9392." + "ObjectForm.navigateOnSuccess": "Explicitly parked pending the 'action success navigation' family ruling (#9474/#9566, objectui#5034) — declaring it now risks freezing a spelling that family is shaping; revisit when `onSuccess` lands. The family landed on 2026-08-18 (PR #9601); the revisit is tracked on #9392.", + "ListView.objectName": "TEMPORARY, with a stated expiry (#14791, maintainer ruling 2026-09-07, option B): the react-tier contract retired the ListView `objectName` / `viewType` overlay aliases with no deprecation window, so objectui plugin-list's designer `inputs` (`objectName` required, `viewType`) are registry-only until objectui#8510 removes them from the list-view registration (the metadata host stamps `objectName`, and `normalizeListViewSchema` supplies it from `data` at pin a472b071). Accepted only until objectui#8510 lands and the pin moves past it — DELETE this entry and the two inputs from the `ListView.registryOnly` row then; declaring the props back in spec or on the overlay is not an exit, it undoes the ruling.", + "ListView.viewType": "TEMPORARY, with a stated expiry (#14791, maintainer ruling 2026-09-07, option B): the react-tier contract retired the ListView `objectName` / `viewType` overlay aliases with no deprecation window, so objectui plugin-list's designer `inputs` (`objectName` required, `viewType`) are registry-only until objectui#8510 removes them from the list-view registration (the metadata host stamps `objectName`, and `normalizeListViewSchema` supplies it from `data` at pin a472b071). Accepted only until objectui#8510 lands and the pin moves past it — DELETE this entry and the two inputs from the `ListView.registryOnly` row then; declaring the props back in spec or on the overlay is not an exit, it undoes the ruling." }, "blocks": { "ObjectForm": { @@ -16,7 +18,10 @@ "missing": false }, "ListView": { - "registryOnly": [], + "registryOnly": [ + "objectName", + "viewType" + ], "missing": false }, "ObjectChart": { diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-react-list-view-binding-aliases-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-react-list-view-binding-aliases-retired.ts new file mode 100644 index 0000000000..07b5322245 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-react-list-view-binding-aliases-retired.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-react-list-view-binding-aliases-retired', + surface: '`kind:\'react\'` page source — `` and `` ' + + '(the react-tier overlay aliases #11284 had published as deprecated)', + replacement: '`` — ListViewSchema\'s own ' + + '`data` data source and `type` view kind, the same two keys a metadata list view authors. ' + + '`objectName="x"` → `data={{ provider: \'object\', object: \'x\' }}`; `viewType="kanban"` → ' + + '`type="kanban"`. A `` with no `data` at all is refused too: on a react page no ' + + 'host stamps the object, so the data source is the required binding there.', + reason: + 'A react page\'s source is a JSX string, not a keyed document: `objectstack migrate meta` ' + + 'rewrites stored metadata by key and cannot rewrite props inside authored source, so the ' + + 'move is by hand. The contract deprecated both aliases in favour of the metadata-tier ' + + 'spelling (#11284) while objectui\'s ListView still read only `objectName`, so the canonical ' + + 'spelling validated green and rendered an empty list. The consumer fold has landed (objectui ' + + '`normalizeListViewSchema`, console pin a472b071: `data.provider === \'object\'` → ' + + '`objectName`, and the author\'s `type` read for the view kind), and the maintainer ruled the ' + + 'aliases retired with no deprecation window (#14791, 2026-09-07). Writing either alias is now ' + + 'a publish-time `react-prop-retired` error carrying this prescription — never a silent pass ' + + 'on a key the renderer happens to still read.', + acceptanceCriteria: + '`objectstack validate` reports no `react-prop-retired` and no `react-prop-missing-required` ' + + 'finding on any `kind:\'react\'` page; every `` carries `data={{ provider: ' + + '\'object\', object }}` (or another ViewData provider) and, where a view kind was chosen, ' + + '`type`; in the console each rewritten list renders the same rows and visualization it ' + + 'rendered under the alias spelling. The platform\'s own sites are the reference: the ' + + 'showcase `crm-workbench`, `renewals-pipeline` and `task-desk` pages pass with zero findings.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1aa751853c..4c684b75e9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9914,6 +9914,34 @@ const step18: MigrationStep = { + 'anything), and behaviour that seems to need one is a renderer capability request ' + 'against objectui, not a metadata key.', }, + { + id: 'ui-react-list-view-binding-aliases-retired', + surface: '`kind:\'react\'` page source — `` and `` ' + + '(the react-tier overlay aliases #11284 had published as deprecated)', + replacement: '`` — ListViewSchema\'s own ' + + '`data` data source and `type` view kind, the same two keys a metadata list view authors. ' + + '`objectName="x"` → `data={{ provider: \'object\', object: \'x\' }}`; `viewType="kanban"` → ' + + '`type="kanban"`. A `` with no `data` at all is refused too: on a react page no ' + + 'host stamps the object, so the data source is the required binding there.', + reason: + 'A react page\'s source is a JSX string, not a keyed document: `objectstack migrate meta` ' + + 'rewrites stored metadata by key and cannot rewrite props inside authored source, so the ' + + 'move is by hand. The contract deprecated both aliases in favour of the metadata-tier ' + + 'spelling (#11284) while objectui\'s ListView still read only `objectName`, so the canonical ' + + 'spelling validated green and rendered an empty list. The consumer fold has landed (objectui ' + + '`normalizeListViewSchema`, console pin a472b071: `data.provider === \'object\'` → ' + + '`objectName`, and the author\'s `type` read for the view kind), and the maintainer ruled the ' + + 'aliases retired with no deprecation window (#14791, 2026-09-07). Writing either alias is now ' + + 'a publish-time `react-prop-retired` error carrying this prescription — never a silent pass ' + + 'on a key the renderer happens to still read.', + acceptanceCriteria: + '`objectstack validate` reports no `react-prop-retired` and no `react-prop-missing-required` ' + + 'finding on any `kind:\'react\'` page; every `` carries `data={{ provider: ' + + '\'object\', object }}` (or another ViewData provider) and, where a view kind was chosen, ' + + '`type`; in the console each rewritten list renders the same rows and visualization it ' + + 'rendered under the alias spelling. The platform\'s own sites are the reference: the ' + + 'showcase `crm-workbench`, `renewals-pipeline` and `task-desk` pages pass with zero findings.', + }, { id: 'ui-record-blocks-unknown-keys-refused', surface: 'page `record:alert` / `record:quick_actions` / `record:history` / ' diff --git a/packages/spec/src/ui/react-blocks.test.ts b/packages/spec/src/ui/react-blocks.test.ts index 424bafe5e0..22ed942e48 100644 --- a/packages/spec/src/ui/react-blocks.test.ts +++ b/packages/spec/src/ui/react-blocks.test.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { REACT_BLOCKS, REACT_OVERLAY_SHADOWS, + REACT_RETIRED_OVERLAY_PROPS, REACT_RECORD_BLOCK_ALTERNATIVES, RECORD_CONTEXT_BLOCK_TAGS, isRecordContextBlockType, @@ -125,6 +126,10 @@ describe('REACT_BLOCKS — the record:* family is out (#4413)', () => { const tags = REACT_BLOCKS.map((b) => b.tag); expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']).toContain('ListView'); expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:details']).toContain('ObjectForm'); + // #14791: the related-list prescription binds in the only spelling the + // contract publishes — never the retired `objectName` alias. + expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']).toContain("provider: 'object'"); + expect(REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']).not.toContain('objectName'); expect(tags).toContain('ListView'); expect(tags).toContain('ObjectForm'); }); @@ -132,13 +137,14 @@ describe('REACT_BLOCKS — the record:* family is out (#4413)', () => { /** * #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. + * deprecate-first (maintainer ruling 2026-08-23, recorded on-card): the + * canonical spellings were declared and the old ones kept as deprecated + * aliases. #14791 (maintainer ruling 2026-09-07) is the removal card: the + * ListView aliases are RETIRED with no window, and these pins hold that in + * both directions — the canonical props must be published, and a retired + * spelling must not quietly come back (or stay published beside its tombstone). */ -describe('REACT_BLOCKS — deprecate-first vocabulary convergence (#11284)', () => { +describe('REACT_BLOCKS — vocabulary convergence (#11284) and the ListView alias retirement (#14791)', () => { 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 @@ -174,23 +180,46 @@ describe('REACT_BLOCKS — deprecate-first vocabulary convergence (#11284)', () } }); - it('ListView: objectName→data and viewType→type, canonical props surfaced, aliases still published', () => { + it('ListView: objectName / viewType are RETIRED — data / type are the only spellings, data required (#14791)', () => { 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' }); + const names = lv.interactions.map((i) => i.name); + // No alias and no deprecation row: retired with no window. + expect(names).not.toContain('objectName'); + expect(names).not.toContain('viewType'); + expect(lv.interactions.filter((i) => i.deprecated)).toEqual([]); + // The canonical spellings are published — `type` from the schema, `data` + // restated as the required binding (ledgered in REACT_OVERLAY_SHADOWS): + // a react page has no host to stamp the object, so "bind something" is + // still said by the contract, now in the metadata-tier spelling. 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); + const data = lv.interactions.find((i) => i.name === 'data')!; + expect(data.kind).toBe('binding'); + expect(data.required).toBe(true); + expect(REACT_OVERLAY_SHADOWS.ListView).toContain('data'); + }); + + it('a retired overlay prop is gone from its block, names a real prop, and carries a fix', () => { + const tags = new Set(REACT_BLOCKS.map((b) => b.tag)); + for (const [tag, props] of Object.entries(REACT_RETIRED_OVERLAY_PROPS)) { + expect(tags.has(tag), `${tag} is not a block`).toBe(true); + const b = REACT_BLOCKS.find((x) => x.tag === tag)!; + const live = new Set([ + ...b.interactions.map((i) => i.name), + ...(b.schema ? schemaPropNames(b.schema) : []), + ]); + for (const [name, r] of Object.entries(props)) { + // A tombstone beside a live prop of the same name would make the lint + // refuse what the contract still publishes. + expect(live.has(name), `<${tag}> ${name} is retired AND still published`).toBe(false); + expect(live.has(r.replacedBy), `<${tag}> ${name} → "${r.replacedBy}" names no prop on the block`).toBe(true); + expect(r.note.length).toBeGreaterThan(0); + } + } + // The ruled pair, exactly — objectui#2890 A6's mapping, now as tombstones. + expect( + Object.fromEntries(Object.entries(REACT_RETIRED_OVERLAY_PROPS.ListView).map(([k, v]) => [k, v.replacedBy])), + ).toEqual({ objectName: 'data', viewType: 'type' }); }); it('ObjectForm and ObjectChart objectName are NOT converged by this step', () => { diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index b4fa016135..448ce84483 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -39,7 +39,9 @@ export interface ReactInteractionProp { * 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. + * deprecation window — removal is a later card, never a side effect here, and + * lands as a {@link REACT_RETIRED_OVERLAY_PROPS} entry so the lint refuses the + * retired spelling by name. * * `replacedBy` names the canonical prop ON THE SAME BLOCK (a spec-schema * prop surfaced via `dataProps`, or another overlay prop); `note` is the @@ -74,11 +76,48 @@ export interface ReactInteractionProp { * MEANING-PRESERVING, and the reason has to be written down next to it. */ export const REACT_OVERLAY_SHADOWS: Readonly> = { - // Same prop, same meaning. The overlay restates it to publish the JSX - // shorthand an author writes (`navigation={{ mode: 'none' }}`) together with - // the React-side rule that pairs it with `onRowClick` — neither of which the - // declarative schema has anywhere to say. - ListView: ['navigation'], + // `navigation` — same prop, same meaning. The overlay restates it to publish + // the JSX shorthand an author writes (`navigation={{ mode: 'none' }}`) + // together with the React-side rule that pairs it with `onRowClick` — + // neither of which the declarative schema has anywhere to say. + // `data` — same prop, same meaning (#14791). The overlay restates it to say + // the one thing the declarative schema cannot: a metadata page's host stamps + // the bound object, a `kind:'react'` page's does not, so the data source is + // REQUIRED to write on this tier — the binding `objectName` carried until + // its retirement, now spelled `data={{ provider: 'object', object }}` only. + ListView: ['data', 'navigation'], +}; + +/** + * Overlay props RETIRED from a block, per tag — the react-tier twin of + * `retiredKey()` on a metadata schema (#14791, maintainer ruling 2026-09-07: + * the #11284 aliases go with no deprecation window). The spelling is gone + * from {@link REACT_BLOCKS}, so the generated contract no longer publishes it; + * this ledger is what lets `validate-react-page-props` refuse it BY NAME, with + * the prescription, instead of passing it as an unknown prop the renderer may + * happen to still read. + * + * `replacedBy` names the prop on the same block that carries the binding now + * (a spec-schema prop surfaced via `dataProps`, or an overlay prop); `note` is + * the one-line fix the lint quotes verbatim. An entry must not also be a live + * prop of its block — `react-blocks.test.ts` pins both directions. + */ +export const REACT_RETIRED_OVERLAY_PROPS: Readonly< + Record>> +> = { + ListView: { + // #11284 published these as deprecated aliases of ListViewSchema's own + // `data` / `type`; #14791 retires them outright, the consumer fold having + // landed (objectui `normalizeListViewSchema`, console pin a472b071). + objectName: { + replacedBy: 'data', + note: "Write data={{ provider: 'object', object: '…' }} — the metadata-tier data source, the same spelling a metadata list view authors.", + }, + viewType: { + replacedBy: 'type', + note: 'Write type="kanban" — ListViewSchema\'s own `type`, the metadata-tier view kind.', + }, + }, }; /** @@ -164,7 +203,7 @@ export const REACT_RECORD_BLOCK_ALTERNATIVES: Readonly> = 'record:highlights': ', or read the record with useAdapter().findOne and lay the strip out in JSX.', 'record:related_list': - '\', \'=\', parentId]} columns={[…]} /> — the parent binding is an ordinary filter on a react page.', + "' }} filters={['', '=', parentId]} columns={[…]} /> — the parent binding is an ordinary filter on a react page.", 'record:path': 'read the record with useAdapter().findOne and render the stage bar in JSX (layout is this tier\'s job).', }; @@ -250,42 +289,31 @@ 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. 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.", + 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`: the same two keys a metadata list view authors, and the only spellings (the `objectName` / `viewType` aliases are retired).", schema: ListViewSchema, // #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. + // metadata-tier vocabulary — `type` and `data` are ListViewSchema's own + // props (objectui#2890 A6). #14791 (maintainer ruling 2026-09-07) retired + // the overlay aliases `objectName` / `viewType` with no window once the + // consumer fold landed (objectui `normalizeListViewSchema`, console pin + // a472b071): the canonical spelling is the ONLY spelling, and + // `REACT_RETIRED_OVERLAY_PROPS` carries the tombstone the lint quotes. // `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: [ - // #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). + // NOT the shared OBJECT_NAME: ListView's object binding IS the schema's + // `data` data source. Restated here — ledgered in REACT_OVERLAY_SHADOWS — + // because the react tier has to say what the declarative schema cannot: + // a metadata page's host stamps the bound object, a react page's does + // not, so on this tier the data source is required to WRITE. { - name: 'objectName', - type: 'string', + name: 'data', + type: "ViewData — { provider: 'object', object: string } binds an object", 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; 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: write `type`, the same key a metadata list view authors.', + description: "The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}. Any ViewData provider is accepted (a 'value' provider renders static rows); nothing stamps the object on a react page, so this prop is required.", }, { 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.' }, diff --git a/scripts/check-react-page-adapter-contract.mjs b/scripts/check-react-page-adapter-contract.mjs index e3a634b8ef..4d29528d0b 100644 --- a/scripts/check-react-page-adapter-contract.mjs +++ b/scripts/check-react-page-adapter-contract.mjs @@ -1000,7 +1000,7 @@ export function selfTest() { 'a tagged fence is selected on a real adapter.find', ); assert( - !isReactPageSample({ lang: 'jsx', body: `` }), + !isReactPageSample({ lang: 'jsx', body: `` }), 'a plain block fence is NOT selected — admitting every jsx fence is the fabrication direction', ); assert( diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 98c1ff47e6..d7af9b769b 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -54,12 +54,11 @@ 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. 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. +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`: the same two keys a metadata list view authors, and the only spellings (the `objectName` / `viewType` aliases are retired). | prop | type | kind | required | description | |------|------|------|:--------:|-------------| -| `objectName` | `string` | binding | ✓ | [DEPRECATED → `data={{ provider: 'object', object }}`] The object this block binds to (server-connected). Converging on the metadata-tier spelling; 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: write `type`, the same key a metadata list view authors. | +| `data` | `ViewData — { provider: 'object', object: string } binds an object` | binding | ✓ | The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}. Any ViewData provider is accepted (a 'value' provider renders static rows); nothing stamps the object on a react page, so this prop is required. | | `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. | @@ -68,7 +67,6 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `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' \| 'page'` | 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 | diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index ee2d7f3361..489217dc5d 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -174,14 +174,14 @@ The source is real React executed at render by the runtime. The injected scope a ``, ``, ``, ``, …): the registry injects a wrapper for each, but every one of them renders from the record context a **record page** mounts, which a react page never does — so - they come back empty however you bind them. `os validate` rejects them here + they come back empty. `os validate` rejects them here (`react-block-needs-record-context`), by tag and via ``. On a react page the parent record is ordinary React state, so use the blocks - that read their own props: `', '=', parentId]}>` for a related list, `` for a field panel, plain JSX over `useAdapter().findOne` for a - highlights strip or a stage bar. Need the family itself? Author the page as - `type:'record'`, where the context exists + that read their own props: `' }} filters={['', '=', parentId]}>` for a related list, + `` for a field panel, plain JSX over + `useAdapter().findOne` for a highlights strip or a stage bar. Need the family + itself? Author the page as `type:'record'`, where the context exists - `data` / `variables` / `page` Compose **layout with inline `style={{…}}`** (real CSS); use the injected blocks @@ -215,7 +215,7 @@ function Page() { const [reload, setReload] = React.useState(0); return (
- setSel(r)} /> {sel