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
42 changes: 42 additions & 0 deletions .changeset/react-tier-record-blocks-withdrawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413)

The react-tier contract published `objectName` / `recordId` on
`<RecordDetails>`, `<RecordHighlights>`, `<RecordRelatedList>` and
`<RecordPath>`, and no renderer read either prop. All ten `record:*` renderers
take their record from `useRecordContext()`, which only the record route
(`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever
mount; the `kind:'react'` page renderer wraps the page in a
`SchemaRendererProvider` alone. So the blocks rendered their "bind a record to
preview" placeholder — or, for `record:related_list` (the one that does read
`schema.objectName`), refused to fetch because the parent id never arrived. A
page authored exactly to contract came back EMPTY with nothing reported
anywhere, including by `os validate`, which resolved those props' field names
against the object they named: lint standing guard over a binding that never
ran.

Withdrawn rather than implemented. The contract was not merely unimplemented,
it was the wrong SHAPE: per-block bindings describe four independent fetches of
one record, which is exactly the coupling the shared record context exists to
prevent (`record:details` drops the fields a mounted `record:highlights`
registered; one inline-edit save bar commits them all under a single
`ifMatch`). Honoring the props would have fossilized that (Prime Directive
#12). The naming of that primitive — a record SCOPE an author wraps around the
family, one fetch, shared context — is the open design question, filed as #4444.

`@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the
ledger for why, plus the working replacement per type. The family is derived
from `ComponentPropsMap`, so a record component added later is gated the day it
lands — including the six that were never in the contract but are just as
reachable through the registry-built react scope.

`@objectstack/lint` gains `react-block-needs-record-context` (error), which
rejects them on a react page by tag and through `<Block type="record:…">`
alike, quoting the block that does work: `<ListView filters={['<lookup>', '=',
parentId]}>` for a related list, `<ObjectForm mode="view" recordId={…}>` for a
field panel. A locally-declared component of the same name shadows the injected
scope and is left alone.
40 changes: 30 additions & 10 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -290,16 +290,36 @@ filter chip, or one form field short.
Checked on every injected block: `<ListView>`'s
`fields`/`columns`/`sort`/`grouping`/`userFilters`, `<ObjectForm>`'s `fields`,
`initialValues` keys, `sections[].fields[]` and `subforms` (each against its own
`childObject`), and `<RecordHighlights>` / `<RecordDetails>` / `<RecordPath>` /
`<RecordRelatedList>` — those last four through the **same** descriptor table
§5 uses, so a component's field-bearing props are described once and checked on
both surfaces. `<Block type="…">` reaches that table by the type the author
writes, so the escape hatch is covered rather than left as a hole.

`<RecordRelatedList objectName>` is the **related (child)** object whose records
are listed — the parent record is bound by `recordId`, and `relationshipField`
is the child's field pointing back at it. Passing the parent there is the
mistake this check was extended to catch.
`childObject`). `<Block type="…">` reaches the §5 descriptor table by the type
the author writes, so the escape hatch is covered rather than left as a hole.

### 10b. A `record:*` block on a react page

The `record:*` family — `<RecordDetails>`, `<RecordHighlights>`,
`<RecordRelatedList>`, `<RecordPath>`, and the rest — renders from the record
context a **record page** mounts once for the record it routed to. A
`kind:'react'` page mounts no such context, so these blocks render empty
whatever props they are given; the react contract published `objectName` /
`recordId` for four of them and no renderer ever read either.

```jsx
<RecordHighlights objectName="crm_account" recordId={sel} fields={['name']} />
// ↑ error: renders empty here — those props are not read
```

They are withdrawn from the react tier, and using one is an **error**
(`react-block-needs-record-context`) — by tag, and through
`<Block type="record:…">` alike. On a react page the parent record is ordinary
React state, so bind it with a block that reads its own props: `<ListView
objectName="<child>" filters={['<lookup>', '=', parentId]}>` for a related list,
`<ObjectForm mode="view" recordId={…}>` 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 `<RecordRelatedList objectName>` is the **related (child)** object
whose records are listed — the parent record comes from the page, and
`relationshipField` is the child's field pointing back at it. Passing the parent
there is the mistake that check was extended to catch.

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

Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Components are the building blocks placed inside regions.
The `type` field is a union of the standard `PageComponentType` enum and any custom string. The standard (namespaced) component types include:

- **Structure:** `page:header`, `page:footer`, `page:sidebar`, `page:tabs`, `page:accordion`, `page:card`, `page:section`
- **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`
- **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)
- **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb`
- **Utility:** `global:search`, `global:notifications`, `user:profile`
- **AI:** `ai:chat_window`, `ai:suggestion`
Expand Down
35 changes: 21 additions & 14 deletions examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { definePage } from '@objectstack/spec/ui';
* Renewals Pipeline — a `kind:'react'` business scenario (ADR-0081).
*
* A renewals manager works a list of accounts by lifecycle stage; selecting one
* drives a 360° panel (highlights + invoices + a value-by-status chart) and a
* pre-styled `<ObjectForm formType="drawer">` to update the account in place.
* drives a 360° panel (account summary + invoices + a value-by-status chart) and
* a pre-styled `<ObjectForm formType="drawer">` to update the account in place.
* Every block prop is taken straight from the react-tier contract
* (skills/objectstack-ui/references/react-blocks.md).
*
* The 360 panel deliberately shows BOTH rollup styles side by side:
* • hand-rolled — a `useAdapter()` effect counts related projects/invoices
* into a KPI strip (full control, you own loading/refresh), vs
* • framework blocks — `<ObjectChart>`/`<RecordRelatedList>` do the same
* cross-object reads declaratively (zero data code).
* • framework blocks — `<ObjectChart>`/`<ListView>` do the same cross-object
* reads declaratively (zero data code).
* (This comparison absorbed the former Account Cockpit page.)
*
* The chart is written in the spec `ChartConfig` shape (#3729) and its axes are
Expand All @@ -24,14 +24,20 @@ import { definePage } from '@objectstack/spec/ui';
* `groupBy`) and `total` (its `field`) — not by a dataset-style measure name.
* `os validate` checks both halves.
*
* `<RecordRelatedList>` binds the CHILD object it lists (`showcase_invoice`),
* not the parent — the parent is `recordId`, and `relationshipField="account"`
* is the invoice's lookup back to it. This page used to pass the parent, which
* is what #4340 found: the react contract had glossed `objectName` as "the
* parent object" while the schema (and the renderer behind both surfaces) read
* it as the related one, so the list resolved `total` against an account and
* came back empty. Every field-bearing prop on the page is now checked against
* the object it actually names.
* The selected account is bound BY REACT STATE, not by a record context: `sel`
* is the parent id, so the invoice list is an ordinary `<ListView>` filtered on
* the child's lookup (`['account', '=', sel]`) and the summary is an
* `<ObjectForm mode="view">`. Both read their binding from their own props,
* which is what makes them work on this tier.
*
* This panel used to be `<RecordHighlights>` + `<RecordRelatedList>`, and both
* rendered EMPTY here (#4413): every `record:*` block takes its record from the
* context a record page mounts, and a react page mounts none — the
* `objectName`/`recordId` the contract published for them were read by no
* renderer. They are out of the react tier now, and `os validate` rejects them
* on this surface rather than letting the next author rediscover it at runtime.
* (#4340's finding still holds where it applies: on a RECORD page
* `<RecordRelatedList objectName>` is the CHILD object, never the parent.)
*
* Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`;
* data blocks and the drawer bring their own compiled styling. The drawer sets
Expand Down Expand Up @@ -121,7 +127,7 @@ function Page() {
<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>
</div>

<RecordHighlights objectName="showcase_account" recordId={sel} fields={['name', 'status']} layout="horizontal" />
<ObjectForm objectName="showcase_account" mode="view" recordId={sel} fields={['name', 'status']} />

<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<Stat label="Projects" value={related.projects} />
Expand All @@ -131,7 +137,8 @@ function Page() {

<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} />

<RecordRelatedList objectName="showcase_invoice" recordId={sel} relationshipField="account" columns={['name', 'status', 'total']} limit={5} showViewAll={true} title="Invoices" />
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, color: 'hsl(var(--foreground))' }}>Invoices</h3>
<ListView objectName="showcase_invoice" filters={['account', '=', sel]} columns={['name', 'status', 'total']} navigation={{ mode: 'none' }} />

{editing ? (
<ObjectForm objectName="showcase_account" mode="edit" recordId={sel}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export {
REACT_CHART_FIELD_UNKNOWN,
REACT_CHART_AGGREGATE_INVALID,
REACT_CHART_AXIS_UNKNOWN,
REACT_BLOCK_NEEDS_RECORD_CONTEXT,
} from './validate-react-page-props.js';
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';
Expand Down
10 changes: 9 additions & 1 deletion packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,20 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites },
// The `kind:'react'` page surface. Every prop a react block binds BY FIELD
// NAME is resolved against the object it names (#4340) — `<ListView columns>`,
// `<ObjectForm fields>`, the `record:*` family through the SAME
// `<ObjectForm fields>`, `<Block type="element:…">` through the SAME
// `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface
// over, plus `<ObjectChart>`'s aggregate/axes (#3701/#3729) and
// `searchableFields` (#4329). Squarely the charter's question, on the surface
// where it had no answer at all.
//
// It also carries `react-block-needs-record-context` (#4413) — a BINDING
// question rather than a resolution one: the `record:*` family reads its
// record from a record page's context, so on THIS surface the binding does
// not exist at all and the props the contract published for it were read by
// no renderer. This rule used to resolve those props' field names against
// the object they named — lint standing guard over a binding that never ran.
// It rejects the blocks now, out of the same parse.
//
// It was hand-wired into `os validate` ALONE, so `os lint` and `os compile`
// accepted a react page whose every field binding was stale — including the
// gating ones (a missing required binding, a filter position naming no field:
Expand Down
Loading
Loading