Skip to content

Commit ebb209c

Browse files
os-zhuangclaude
andauthored
fix(spec,lint): 把 record:* 从 react 档契约里撤回——它 publish 的 props 没有任何渲染器读 (#4413) (#4450)
react 档契约在 <RecordDetails> / <RecordHighlights> / <RecordRelatedList> / <RecordPath> 上 publish 了 objectName / recordId,没有任何渲染器读这两个 prop。 十个 record:* 渲染器全部从 useRecordContext() 取记录,而该 context 只有记录路由 (RecordDetailView) 和元数据编辑器预览 (PagePreview) 挂载;react-page 只把页面包进 SchemaRendererProvider。完全照契约写出来的页面渲染成空,且全链路无报错——包括 os validate,它还在把这些 prop 的字段名解析到它们命名的对象上。 撤回而非实现:这份契约的形状本身就是错的。每 block 各带绑定描述的是"四个 block 各取一次同一条记录",恰是共享 record context 存在的目的所要防止的耦合方式 (record:details 会去重已挂载 record:highlights 注册的字段;内联编辑保存栏用一个 ifMatch 提交整份草稿)。照 props 修渲染器等于把错误形状固化进契约 (Prime Directive #12)。 该原语要不要有公开名字 (一个作者包在外层的 record SCOPE) 另行由 #4444 记录。 - spec: 四个 block 出 REACT_BLOCKS,留下写明原因的 ledger 和每类型的可用替代。 族成员从 ComponentPropsMap 派生,新增 record 组件当天即被门禁覆盖。 - lint: 新增 react-block-needs-record-context (error),按 tag 与 <Block type="record:…"> 两路都拦,报错直接给出能用的写法;作者本地声明的同名 组件会 shadow 注入 scope,不拦。 - showcase: Renewals Pipeline 改用真能跑的绑定 (<ListView filters> / <ObjectForm mode="view">)。 - 契约、skill、文档同步重生成或订正。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hz1t4rGsHvFTZYWRnRNPD
1 parent 68c02c2 commit ebb209c

15 files changed

Lines changed: 490 additions & 520 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413)
7+
8+
The react-tier contract published `objectName` / `recordId` on
9+
`<RecordDetails>`, `<RecordHighlights>`, `<RecordRelatedList>` and
10+
`<RecordPath>`, and no renderer read either prop. All ten `record:*` renderers
11+
take their record from `useRecordContext()`, which only the record route
12+
(`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever
13+
mount; the `kind:'react'` page renderer wraps the page in a
14+
`SchemaRendererProvider` alone. So the blocks rendered their "bind a record to
15+
preview" placeholder — or, for `record:related_list` (the one that does read
16+
`schema.objectName`), refused to fetch because the parent id never arrived. A
17+
page authored exactly to contract came back EMPTY with nothing reported
18+
anywhere, including by `os validate`, which resolved those props' field names
19+
against the object they named: lint standing guard over a binding that never
20+
ran.
21+
22+
Withdrawn rather than implemented. The contract was not merely unimplemented,
23+
it was the wrong SHAPE: per-block bindings describe four independent fetches of
24+
one record, which is exactly the coupling the shared record context exists to
25+
prevent (`record:details` drops the fields a mounted `record:highlights`
26+
registered; one inline-edit save bar commits them all under a single
27+
`ifMatch`). Honoring the props would have fossilized that (Prime Directive
28+
#12). The naming of that primitive — a record SCOPE an author wraps around the
29+
family, one fetch, shared context — is the open design question, filed as #4444.
30+
31+
`@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the
32+
ledger for why, plus the working replacement per type. The family is derived
33+
from `ComponentPropsMap`, so a record component added later is gated the day it
34+
lands — including the six that were never in the contract but are just as
35+
reachable through the registry-built react scope.
36+
37+
`@objectstack/lint` gains `react-block-needs-record-context` (error), which
38+
rejects them on a react page by tag and through `<Block type="record:…">`
39+
alike, quoting the block that does work: `<ListView filters={['<lookup>', '=',
40+
parentId]}>` for a related list, `<ObjectForm mode="view" recordId={…}>` for a
41+
field panel. A locally-declared component of the same name shadows the injected
42+
scope and is left alone.

content/docs/deployment/validating-metadata.mdx

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -290,16 +290,36 @@ filter chip, or one form field short.
290290
Checked on every injected block: `<ListView>`'s
291291
`fields`/`columns`/`sort`/`grouping`/`userFilters`, `<ObjectForm>`'s `fields`,
292292
`initialValues` keys, `sections[].fields[]` and `subforms` (each against its own
293-
`childObject`), and `<RecordHighlights>` / `<RecordDetails>` / `<RecordPath>` /
294-
`<RecordRelatedList>` — those last four through the **same** descriptor table
295-
§5 uses, so a component's field-bearing props are described once and checked on
296-
both surfaces. `<Block type="…">` reaches that table by the type the author
297-
writes, so the escape hatch is covered rather than left as a hole.
298-
299-
`<RecordRelatedList objectName>` is the **related (child)** object whose records
300-
are listed — the parent record is bound by `recordId`, and `relationshipField`
301-
is the child's field pointing back at it. Passing the parent there is the
302-
mistake this check was extended to catch.
293+
`childObject`). `<Block type="…">` reaches the §5 descriptor table by the type
294+
the author writes, so the escape hatch is covered rather than left as a hole.
295+
296+
### 10b. A `record:*` block on a react page
297+
298+
The `record:*` family — `<RecordDetails>`, `<RecordHighlights>`,
299+
`<RecordRelatedList>`, `<RecordPath>`, and the rest — renders from the record
300+
context a **record page** mounts once for the record it routed to. A
301+
`kind:'react'` page mounts no such context, so these blocks render empty
302+
whatever props they are given; the react contract published `objectName` /
303+
`recordId` for four of them and no renderer ever read either.
304+
305+
```jsx
306+
<RecordHighlights objectName="crm_account" recordId={sel} fields={['name']} />
307+
// ↑ error: renders empty here — those props are not read
308+
```
309+
310+
They are withdrawn from the react tier, and using one is an **error**
311+
(`react-block-needs-record-context`) — by tag, and through
312+
`<Block type="record:…">` alike. On a react page the parent record is ordinary
313+
React state, so bind it with a block that reads its own props: `<ListView
314+
objectName="<child>" filters={['<lookup>', '=', parentId]}>` for a related list,
315+
`<ObjectForm mode="view" recordId={…}>` for a field panel. To use the family
316+
itself, author the page as `type:'record'`.
317+
318+
On a **record page**, where these blocks do work, §5 checks their field-bearing
319+
props, and `<RecordRelatedList objectName>` is the **related (child)** object
320+
whose records are listed — the parent record comes from the page, and
321+
`relationshipField` is the child's field pointing back at it. Passing the parent
322+
there is the mistake that check was extended to catch.
303323

304324
A **filter position** is the exception that gates:
305325

content/docs/ui/pages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ Components are the building blocks placed inside regions.
144144
The `type` field is a union of the standard `PageComponentType` enum and any custom string. The standard (namespaced) component types include:
145145

146146
- **Structure:** `page:header`, `page:footer`, `page:sidebar`, `page:tabs`, `page:accordion`, `page:card`, `page:section`
147-
- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history`
147+
- **Record context:** `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` — each renders from the record context a **record page** mounts, so they belong on a `type:'record'` page. A `kind:'react'` page mounts no such context and `os validate` rejects them there (see Validating metadata §10b)
148148
- **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb`
149149
- **Utility:** `global:search`, `global:notifications`, `user:profile`
150150
- **AI:** `ai:chat_window`, `ai:suggestion`

examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ import { definePage } from '@objectstack/spec/ui';
66
* Renewals Pipeline — a `kind:'react'` business scenario (ADR-0081).
77
*
88
* A renewals manager works a list of accounts by lifecycle stage; selecting one
9-
* drives a 360° panel (highlights + invoices + a value-by-status chart) and a
10-
* pre-styled `<ObjectForm formType="drawer">` to update the account in place.
9+
* drives a 360° panel (account summary + invoices + a value-by-status chart) and
10+
* a pre-styled `<ObjectForm formType="drawer">` to update the account in place.
1111
* Every block prop is taken straight from the react-tier contract
1212
* (skills/objectstack-ui/references/react-blocks.md).
1313
*
1414
* The 360 panel deliberately shows BOTH rollup styles side by side:
1515
* • hand-rolled — a `useAdapter()` effect counts related projects/invoices
1616
* into a KPI strip (full control, you own loading/refresh), vs
17-
* • framework blocks — `<ObjectChart>`/`<RecordRelatedList>` do the same
18-
* cross-object reads declaratively (zero data code).
17+
* • framework blocks — `<ObjectChart>`/`<ListView>` do the same cross-object
18+
* reads declaratively (zero data code).
1919
* (This comparison absorbed the former Account Cockpit page.)
2020
*
2121
* The chart is written in the spec `ChartConfig` shape (#3729) and its axes are
@@ -24,14 +24,20 @@ import { definePage } from '@objectstack/spec/ui';
2424
* `groupBy`) and `total` (its `field`) — not by a dataset-style measure name.
2525
* `os validate` checks both halves.
2626
*
27-
* `<RecordRelatedList>` binds the CHILD object it lists (`showcase_invoice`),
28-
* not the parent — the parent is `recordId`, and `relationshipField="account"`
29-
* is the invoice's lookup back to it. This page used to pass the parent, which
30-
* is what #4340 found: the react contract had glossed `objectName` as "the
31-
* parent object" while the schema (and the renderer behind both surfaces) read
32-
* it as the related one, so the list resolved `total` against an account and
33-
* came back empty. Every field-bearing prop on the page is now checked against
34-
* the object it actually names.
27+
* The selected account is bound BY REACT STATE, not by a record context: `sel`
28+
* is the parent id, so the invoice list is an ordinary `<ListView>` filtered on
29+
* the child's lookup (`['account', '=', sel]`) and the summary is an
30+
* `<ObjectForm mode="view">`. Both read their binding from their own props,
31+
* which is what makes them work on this tier.
32+
*
33+
* This panel used to be `<RecordHighlights>` + `<RecordRelatedList>`, and both
34+
* rendered EMPTY here (#4413): every `record:*` block takes its record from the
35+
* context a record page mounts, and a react page mounts none — the
36+
* `objectName`/`recordId` the contract published for them were read by no
37+
* renderer. They are out of the react tier now, and `os validate` rejects them
38+
* on this surface rather than letting the next author rediscover it at runtime.
39+
* (#4340's finding still holds where it applies: on a RECORD page
40+
* `<RecordRelatedList objectName>` is the CHILD object, never the parent.)
3541
*
3642
* Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`;
3743
* data blocks and the drawer bring their own compiled styling. The drawer sets
@@ -121,7 +127,7 @@ function Page() {
121127
<button onClick={() => setEditing(true)} style={{ borderRadius: 'var(--radius)', border: '1px solid hsl(var(--border))', background: 'transparent', color: 'hsl(var(--foreground))', padding: '6px 12px', fontSize: 14, cursor: 'pointer' }}>Edit account</button>
122128
</div>
123129
124-
<RecordHighlights objectName="showcase_account" recordId={sel} fields={['name', 'status']} layout="horizontal" />
130+
<ObjectForm objectName="showcase_account" mode="view" recordId={sel} fields={['name', 'status']} />
125131
126132
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
127133
<Stat label="Projects" value={related.projects} />
@@ -131,7 +137,8 @@ function Page() {
131137
132138
<ObjectChart objectName="showcase_invoice" type="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]} series={[{ name: 'total', label: 'Invoice value' }]} title="Invoice value by status" showLegend={true} />
133139
134-
<RecordRelatedList objectName="showcase_invoice" recordId={sel} relationshipField="account" columns={['name', 'status', 'total']} limit={5} showViewAll={true} title="Invoices" />
140+
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, color: 'hsl(var(--foreground))' }}>Invoices</h3>
141+
<ListView objectName="showcase_invoice" filters={['account', '=', sel]} columns={['name', 'status', 'total']} navigation={{ mode: 'none' }} />
135142
136143
{editing ? (
137144
<ObjectForm objectName="showcase_account" mode="edit" recordId={sel}

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export {
8080
REACT_CHART_FIELD_UNKNOWN,
8181
REACT_CHART_AGGREGATE_INVALID,
8282
REACT_CHART_AXIS_UNKNOWN,
83+
REACT_BLOCK_NEEDS_RECORD_CONTEXT,
8384
} from './validate-react-page-props.js';
8485
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
8586
export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';

packages/lint/src/reference-integrity-suite.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,20 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
158158
{ name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites },
159159
// The `kind:'react'` page surface. Every prop a react block binds BY FIELD
160160
// NAME is resolved against the object it names (#4340) — `<ListView columns>`,
161-
// `<ObjectForm fields>`, the `record:*` family through the SAME
161+
// `<ObjectForm fields>`, `<Block type="element:…">` through the SAME
162162
// `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface
163163
// over, plus `<ObjectChart>`'s aggregate/axes (#3701/#3729) and
164164
// `searchableFields` (#4329). Squarely the charter's question, on the surface
165165
// where it had no answer at all.
166166
//
167+
// It also carries `react-block-needs-record-context` (#4413) — a BINDING
168+
// question rather than a resolution one: the `record:*` family reads its
169+
// record from a record page's context, so on THIS surface the binding does
170+
// not exist at all and the props the contract published for it were read by
171+
// no renderer. This rule used to resolve those props' field names against
172+
// the object they named — lint standing guard over a binding that never ran.
173+
// It rejects the blocks now, out of the same parse.
174+
//
167175
// It was hand-wired into `os validate` ALONE, so `os lint` and `os compile`
168176
// accepted a react page whose every field binding was stale — including the
169177
// gating ones (a missing required binding, a filter position naming no field:

0 commit comments

Comments
 (0)